Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,11 @@ type BankAccount
{
accountName: String!
accountNumber: String!
accountType: String!

"""Name of the bank institution"""
bank: String!
branchCode: String!

"""Account currency (e.g. JMD, USD)"""
currency: String!
Expand Down Expand Up @@ -357,22 +359,26 @@ type CashoutOffer
@join__type(graph: PUBLIC)
{
"""The rate used when withdrawing to a JMD bank account"""
exchangeRate: JMDCents!
exchangeRate: JMDCents

"""The time at which this offer is no longer accepted by Flash"""
expiresAt: Timestamp!

"""The amount that Flash is charging for it's services"""
"""The amount that Flash is charging for its services"""
flashFee: USDCents!

"""ID of the offer"""
offerId: ID!

"""The amount Flash owes to the user denominated in JMD as cents"""
receiveJmd: JMDCents!
"""
The amount Flash owes to the user denominated in JMD cents (null for USD payouts)
"""
receiveJmd: JMDCents

"""The amount Flash owes to the user denominated in USD as cents"""
receiveUsd: USDCents!
"""
The amount Flash owes to the user denominated in USD cents (null for JMD payouts)
"""
receiveUsd: USDCents

"""The amount the user is sending to flash"""
send: USDCents!
Expand Down Expand Up @@ -628,7 +634,7 @@ type InitiatedCashoutResponse
@join__type(graph: PUBLIC)
{
errors: [Error!]!
journalId: ID
id: ID
}

union InitiationVia
Expand Down Expand Up @@ -1597,6 +1603,9 @@ input RequestCashoutInput
"""Amount in USD cents."""
amount: USDCents!

"""ERPNext bank account identifier to receive the cashout."""
bankAccountId: ID!

"""ID for a USD wallet belonging to the current user."""
walletId: WalletId!
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ meta {
}

post {
url: {{graphqlUrl}}
url: {{flashGraphqlUrl}}
body: graphql
auth: inherit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ body:graphql:vars {
{
"input": {
"walletId": "{{walletId}}",
"amount": 1
"amount": 1,
"bankAccountId": "John Smith JMD - First Global"
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion dev/bruno/Flash GraphQL API/token/mutations/setUsername.bru
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ body:graphql {
body:graphql:vars {
{
"input": {
"username": "Bob"
"username": "jane"
}
}

}

settings {
encodeUrl: true
timeout: 0
}
12 changes: 3 additions & 9 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ exchangeRates:

cashout:
enabled: false
skipPayment: true
minimum:
amount: 0
currency: USD
Expand All @@ -35,19 +36,12 @@ cashout:
from: "notifications@getflash.io"
subject: "New Cashout"

# credentials and accounts defined in the erpnext restore script
frappe:
url: http://frontend.local:8080
sitename: frontend # default sitename for frappe docker container
url: http://flashapp.me.localhost:8000
sitename: flashapp.me.localhost # default sitename for frappe docker container
credentials:
apiKey: "2701937b221364c"
apiSecret: "a05ab1998d20828"
erpnext:
accounts:
ibex:
operating: "Ibex Operating - F"
cashout: "Cashout Payables (JMD) - F"
serviceFees: "Service Fees - F"

sendgrid:
apiKey: "<replace>"
Expand Down
28 changes: 19 additions & 9 deletions dev/erpnext/restore.sh
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
#!/bin/bash
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)

# Check if backup file is provided as argument
if [ -z "$1" ]; then
if [ -z "${1:-}" ]; then
echo "Usage: $0 <backup-file.sql.gz>"
echo "Example: $0 backups/20260122_062420-frontend-database.sql.gz"
exit 1
fi

BACKUP_FILE="$1"
DB_PASSWORD="admin" # defined in docker compose
FRAPPE_BACKEND_SERVICE="frappe-backend"
SITE_NAME="frontend"
RESTORE_DIR="/tmp/restore"

# Check if backup file exists
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi

# Get just the filename from the path
# Get absolute backup path before switching to repo root for docker compose.
BACKUP_FILE=$(cd "$(dirname "$BACKUP_FILE")" && pwd)/$(basename "$BACKUP_FILE")
BACKUP_FILENAME=$(basename "$BACKUP_FILE")

cd "$REPO_ROOT"

# Copy the backup file from host to container restore directory
docker exec flash-frappe-backend-1 mkdir -p /tmp/restore
docker cp "$BACKUP_FILE" flash-frappe-backend-1:/tmp/restore/"$BACKUP_FILENAME"
docker compose exec -T "$FRAPPE_BACKEND_SERVICE" mkdir -p "$RESTORE_DIR"
docker compose cp "$BACKUP_FILE" "$FRAPPE_BACKEND_SERVICE:$RESTORE_DIR/$BACKUP_FILENAME"

# Remove stale locks if present (e.g. from frappe-create-site)
docker exec flash-frappe-backend-1 rm -f /home/frappe/frappe-bench/sites/frontend/locks/*.lock 2>/dev/null || true
docker compose exec -T "$FRAPPE_BACKEND_SERVICE" rm -f "/home/frappe/frappe-bench/sites/$SITE_NAME/locks/"*.lock 2>/dev/null || true

# Restore the database inside the container with the password
docker exec flash-frappe-backend-1 bench --site frontend restore --db-root-password "$DB_PASSWORD" /tmp/restore/"$BACKUP_FILENAME"
docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" restore --db-root-password "$DB_PASSWORD" "$RESTORE_DIR/$BACKUP_FILENAME"

# Run migrate to sync database schema with current code
echo "Migrating frontend"
docker exec flash-frappe-backend-1 bench --site frontend migrate || {
echo "Migrating $SITE_NAME"
docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate || {
echo "Migration failed, retrying..."
sleep 5
docker exec flash-frappe-backend-1 bench --site frontend migrate
docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ import { decodeInvoice, PaymentSendStatus } from "@domain/bitcoin/lightning"
import { Cashout, ExchangeRates } from "@config"
import PersistedOffer from "./storage/PersistedOffer"
import { EmailService } from "@services/email"
import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import { RepositoryError } from "@domain/errors"
import ErpNext from "@services/frappe/ErpNext"
import { BankAccountQueryError } from "@services/frappe/errors"

const config = {
...Cashout.OfferConfig,
...ExchangeRates,
}

const OffersManager = {
createCashoutOffer: async (
const CashoutManager = {
createOffer: async (
walletId: WalletId,
userPayment: USDAmount,
bankAccountId: string,
): Promise<PersistedOffer | Error> => {
const flashWallet = await getBankOwnerIbexAccount()

Expand All @@ -33,27 +38,35 @@ const OffersManager = {
const invoice = decodeInvoice(invoiceResp.invoice.bolt11)
if (invoice instanceof Error) return invoice

const flashFee = userPayment.multiplyBips(config.fee)
const usdLiability = userPayment.subtract(flashFee)
const serviceFee = userPayment.multiplyBips(config.fee)
const usdPayout = userPayment.subtract(serviceFee)
const exchangeRate = config.jmd.sell // todo: get from price server
const jmdLiability = usdLiability.convertAtRate(exchangeRate)
const jmdPayout = usdPayout.convertAtRate(exchangeRate)

const wallet = await WalletsRepository().findById(walletId)
if (wallet instanceof RepositoryError) return new ValidationError(wallet)
const account = await AccountsRepository().findById(wallet.accountId)
if (account instanceof RepositoryError) return new ValidationError(account)
if (!account.erpParty) return new Error("Could not find erpParty for account")

const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty!)
if (bankAccounts instanceof BankAccountQueryError) return bankAccounts
const bankAccount = bankAccounts.find(b => b.name === bankAccountId)
if (!bankAccount) return new ValidationError(`Bank account not found: ${bankAccountId}`)

const isJmdPayout = bankAccount.currency === "JMD"
const payout = isJmdPayout
? { bankAccountId, amount: jmdPayout, serviceFee, exchangeRate }
: { bankAccountId, amount: usdPayout, serviceFee }

const validated = await ValidOffer.from({
ibexTrx: {
payment: {
userAcct: walletId,
flashAcct: flashWallet,
invoice,
usd: userPayment,
// currency: "USD",
},
flash: {
liability: {
usd: usdLiability,
jmd: jmdLiability,
},
exchangeRate,
fee: flashFee,
amount: userPayment,
},
payout,
})
if (validated instanceof ValidationError) return validated

Expand All @@ -66,7 +79,7 @@ const OffersManager = {
const offer = await Storage.get(id)
if (offer instanceof Error) return offer

if (walletId !== offer.details.ibexTrx.userAcct) return new ValidationError("Offer is not good for provided wallet.")
if (walletId !== offer.details.payment.userAcct) return new ValidationError("Offer is not good for provided wallet.")

const validOffer = await ValidOffer.from(offer.details)
if (validOffer instanceof Error) return validOffer
Expand All @@ -81,4 +94,4 @@ const OffersManager = {

}

export default OffersManager
export default CashoutManager
45 changes: 26 additions & 19 deletions src/app/offers/ValidOffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { CashoutValidator } from "./Validator"
import { RepositoryError } from "@domain/errors"
import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import Ibex from "@services/ibex/client"
import { EmailService } from "@services/email"
import { CashoutDetails, ValidationInputs } from "./types"
import ErpNext from "@services/frappe/ErpNext"
import { JournalEntryDraftError, JournalEntrySubmitError } from "@services/frappe/errors"
import ErpNext, { CashoutId } from "@services/frappe/ErpNext"
import { JournalEntryDraftError, CashoutSubmitError } from "@services/frappe/errors"
import { baseLogger } from "@services/logger"
import { IbexError } from "@services/ibex/errors"
import { Cashout } from "@config"

// Only way to construct a ValidOffer is using the static method which contains validations
class ValidOffer extends Offer {
Expand All @@ -27,7 +27,7 @@ class ValidOffer extends Offer {
static from = async (
details: CashoutDetails,
): Promise<ValidOffer | ValidationError> => {
const wallet = await WalletsRepository().findById(details.ibexTrx.userAcct)
const wallet = await WalletsRepository().findById(details.payment.userAcct)
if (wallet instanceof RepositoryError) return new ValidationError(wallet)

const account = await AccountsRepository().findById(wallet.accountId)
Expand All @@ -42,25 +42,32 @@ class ValidOffer extends Offer {
}

async execute(): Promise<InitiatedCashout | Error> {
const journal = await ErpNext.draftCashout(this)
if (journal instanceof JournalEntryDraftError) return journal
const id = journal.journalId
const cashoutId = await ErpNext.draftCashout(this)
if (cashoutId instanceof JournalEntryDraftError) return cashoutId

const resp = await Ibex.payInvoice({
accountId: this.details.ibexTrx.userAcct,
invoice: this.details.ibexTrx.invoice.paymentRequest as unknown as Bolt11,
})
if (resp instanceof IbexError) {
ErpNext.delete(id) // clean up accounting entry
return resp
if (!Cashout.SkipPayment) {
const resp = await Ibex.payInvoice({
accountId: this.details.payment.userAcct,
invoice: this.details.payment.invoice.paymentRequest as unknown as Bolt11,
})
if (resp instanceof IbexError) {
baseLogger.error({ resp }, "Failed to pay invoice for cashout")
return resp
}
} else {
baseLogger.warn({ cashoutId }, "Skipping Ibex payment (skipPayment=true)")
}

const submitted = await ErpNext.submit(id)
if (submitted instanceof JournalEntrySubmitError) {
baseLogger.error({ submitted }, "Failed to submit journal after payment sent")
let submitted = await ErpNext.submitCashout(cashoutId)
if (submitted instanceof CashoutSubmitError) {
baseLogger.warn({ cashoutId }, "submitCashout failed, retrying")
submitted = await ErpNext.submitCashout(cashoutId)
if (submitted instanceof CashoutSubmitError) {
baseLogger.error({ cashoutId }, "submitCashout failed after retry — manual intervention required")
}
}

return new InitiatedCashout(this, id)
return new InitiatedCashout(this, cashoutId)
}
}

Expand All @@ -70,5 +77,5 @@ export default ValidOffer

export class InitiatedCashout {
readonly status = PaymentSendStatus.Pending
constructor(readonly offer: ValidOffer, readonly journalId: LedgerJournalId) {}
constructor(readonly offer: ValidOffer, readonly cashoutId: CashoutId) {}
}
Loading