diff --git a/.env.example b/.env.example index 30438c6..cc05091 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ LIVEMODE= # Optional base58 secret key that sponsors Solana checkout network fees / rent. # When set, buyers only co-sign; the API cosigns and broadcasts. # When unset, buyers pay fees via wallet signAndSend. +# Dashboard payouts also use this key; for payouts it must match the platform's +# default wallet and that wallet must hold both the payout USDC and fee SOL. # TRANSACTION_FEE_PAYER_KEY= # Subscription billing: in-process poller (single-instance Docker / local only). diff --git a/.env.production.example b/.env.production.example index dbc9172..cfd5724 100644 --- a/.env.production.example +++ b/.env.production.example @@ -53,6 +53,8 @@ LIVEMODE=true # Optional base58 secret key that sponsors Solana checkout network fees / rent. # When set, buyers only co-sign; the API cosigns and broadcasts. # When unset, buyers pay fees via wallet signAndSend. +# Dashboard payouts also use this key; for payouts it must match the platform's +# default wallet and that wallet must hold both the payout USDC and fee SOL. # TRANSACTION_FEE_PAYER_KEY= # Subscription billing: leave the in-process poller off for Cloud Run. diff --git a/apps/api/src/__tests__/Payout.spec.ts b/apps/api/src/__tests__/Payout.spec.ts index b9709b9..97cb1b0 100644 --- a/apps/api/src/__tests__/Payout.spec.ts +++ b/apps/api/src/__tests__/Payout.spec.ts @@ -34,6 +34,10 @@ jest.mock('../modules/chains/Solana', () => ({ last_valid_block_height: 100000, recipients_count: 1, }), + GetCheckoutFeePayerPublicKey: jest.fn().mockReturnValue('platform_wallet'), + SignAndSimulatePayoutTransaction: jest + .fn() + .mockResolvedValue('signed_base64tx'), BroadcastSignedTransaction: jest.fn().mockResolvedValue({ status: 'paid', signature: 'sig123', @@ -200,4 +204,128 @@ describe('PayoutModule', () => { ); }); }); + + describe('CreateAndProcessDashboardPayout', () => { + const payout = { + id: 'po_z_1', + object: 'payout', + account: 'acct_z_seller', + platform_account: 'acct_z_platform', + amount: 1000, + currency: 'usdc', + destination: 'wa_z_1', + status: 'pending', + } as Payout; + + const input = { + amount: 1000, + currency: 'usdc', + destination: 'wa_z_1', + }; + + it('rejects a signer mismatch before creating the payout', async () => { + jest + .spyOn(module as any, 'GetPlatformWalletPublicKey') + .mockResolvedValue('different_wallet'); + const createSpy = jest.spyOn(module, 'CreatePayout'); + + await expect( + module.CreateAndProcessDashboardPayout( + 'acct_z_platform', + 'acct_z_seller', + input + ) + ).rejects.toThrow( + 'TRANSACTION_FEE_PAYER_KEY environment variable must match the platform payout wallet' + ); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('reuses the build and broadcast flow for a dashboard payout', async () => { + jest + .spyOn(module as any, 'GetPlatformWalletPublicKey') + .mockResolvedValue('platform_wallet'); + jest.spyOn(module, 'CreatePayout').mockResolvedValue(payout); + jest.spyOn(module, 'BuildPayoutsBatch').mockResolvedValue({ + object: 'payout_batch_build', + unsigned_transaction: 'base64tx', + estimated_fee_lamports: 5000, + blockhash: 'blockhash123', + last_valid_block_height: 100000, + payouts: [payout], + total_amount: 1000, + recipients_count: 1, + }); + const broadcastSpy = jest + .spyOn(module, 'BroadcastPayoutsBatch') + .mockResolvedValue({ + object: 'payout_batch_broadcast', + signature: 'sig123', + status: 'paid', + viewer_url: 'https://solscan.io/tx/sig123', + payouts: [{ ...payout, status: 'paid' }], + }); + + const result = await module.CreateAndProcessDashboardPayout( + 'acct_z_platform', + 'acct_z_seller', + input + ); + + expect(result.status).toBe('paid'); + expect(broadcastSpy).toHaveBeenCalledWith('acct_z_platform', { + signed_transaction: 'signed_base64tx', + payouts: ['po_z_1'], + blockhash: 'blockhash123', + last_valid_block_height: 100000, + }); + }); + + it('fails and refunds a payout when signing or simulation fails', async () => { + jest + .spyOn(module as any, 'GetPlatformWalletPublicKey') + .mockResolvedValue('platform_wallet'); + jest.spyOn(module, 'CreatePayout').mockResolvedValue(payout); + jest.spyOn(module, 'BuildPayoutsBatch').mockResolvedValue({ + object: 'payout_batch_build', + unsigned_transaction: 'base64tx', + estimated_fee_lamports: 5000, + blockhash: 'blockhash123', + last_valid_block_height: 100000, + payouts: [payout], + total_amount: 1000, + recipients_count: 1, + }); + (module as any).solana.SignAndSimulatePayoutTransaction.mockRejectedValue( + new Error('Simulation failed') + ); + const processingPayout = { ...payout, status: 'processing' } as Payout; + const failedPayout = { + ...payout, + status: 'failed', + failure_message: 'Simulation failed', + } as Payout; + jest + .spyOn(module, 'GetPayout') + .mockResolvedValueOnce(processingPayout) + .mockResolvedValueOnce(failedPayout); + const failSpy = jest + .spyOn(module as any, 'MarkPayoutFailed') + .mockResolvedValue(undefined); + + const result = await module.CreateAndProcessDashboardPayout( + 'acct_z_platform', + 'acct_z_seller', + input + ); + + expect(failSpy).toHaveBeenCalledWith( + processingPayout, + 'blockchain_error', + 'Simulation failed' + ); + expect(result.status).toBe('failed'); + expect(result.payouts).toEqual([failedPayout]); + }); + }); }); diff --git a/apps/api/src/modules/Payout.ts b/apps/api/src/modules/Payout.ts index dac25c7..d298f9e 100644 --- a/apps/api/src/modules/Payout.ts +++ b/apps/api/src/modules/Payout.ts @@ -669,6 +669,88 @@ export class PayoutModule { }; } + /** + * Create and process a payout initiated from the platform dashboard. + * + * The configured fee payer must also be the platform wallet because that + * wallet owns the USDC being transferred. + */ + async CreateAndProcessDashboardPayout( + platformAccountId: string, + connectedAccountId: string, + input: CreatePayoutInput + ): Promise { + const platformWalletPublicKey = await this.GetPlatformWalletPublicKey( + platformAccountId + ); + let configuredSigner: string; + try { + configuredSigner = this.solana.GetCheckoutFeePayerPublicKey(); + } catch { + throw new AppError( + 'TRANSACTION_FEE_PAYER_KEY environment variable is required for dashboard payouts', + 400, + 'invalid_request_error' + ); + } + + if (configuredSigner !== platformWalletPublicKey) { + throw new AppError( + 'TRANSACTION_FEE_PAYER_KEY environment variable must match the platform payout wallet', + 400, + 'invalid_request_error' + ); + } + + const payout = await this.CreatePayout(connectedAccountId, input); + let buildResult: PayoutBatchBuildResponse; + + try { + buildResult = await this.BuildPayoutsBatch(platformAccountId, { + payouts: [payout.id], + }); + + const signedTransaction = + await this.solana.SignAndSimulatePayoutTransaction( + buildResult.unsigned_transaction, + platformWalletPublicKey + ); + + return this.BroadcastPayoutsBatch(platformAccountId, { + signed_transaction: signedTransaction, + payouts: [payout.id], + blockhash: buildResult.blockhash, + last_valid_block_height: buildResult.last_valid_block_height, + }); + } catch (error: unknown) { + const failureMessage = + error instanceof Error ? error.message : 'Failed to process payout'; + const currentPayout = await this.GetPayout(payout.id); + + if ( + currentPayout && + (currentPayout.status === 'pending' || + currentPayout.status === 'processing') + ) { + await this.MarkPayoutFailed( + currentPayout, + 'blockchain_error', + failureMessage + ); + } + + const failedPayout = await this.GetPayout(payout.id); + return { + object: 'payout_batch_broadcast', + signature: '', + status: 'failed', + viewer_url: '', + payouts: failedPayout ? [failedPayout] : [], + failure_message: failureMessage, + }; + } + } + /** * Broadcast a signed batch payout transaction and update payout statuses. * diff --git a/apps/api/src/modules/chains/Solana.ts b/apps/api/src/modules/chains/Solana.ts index eb0c70a..7960859 100644 --- a/apps/api/src/modules/chains/Solana.ts +++ b/apps/api/src/modules/chains/Solana.ts @@ -357,6 +357,70 @@ export class Solana { }; } + /** + * Return the public key for the configured server-side fee payer. + */ + GetCheckoutFeePayerPublicKey(): string { + const keypair = Keypair.fromSecretKey( + bs58.decode(this.RequireCheckoutFeePayerSecretKey()) + ); + return keypair.publicKey.toBase58(); + } + + /** + * Sign and simulate a server-built payout transaction. + * + * The configured key must match the expected platform wallet and must be a + * required signer on the transaction. + */ + async SignAndSimulatePayoutTransaction( + unsignedTransaction: string, + expectedSigner: string + ): Promise { + const transaction = Transaction.from( + Buffer.from(unsignedTransaction, 'base64') + ); + const signerKeypair = Keypair.fromSecretKey( + bs58.decode(this.RequireCheckoutFeePayerSecretKey()) + ); + const signerPublicKey = signerKeypair.publicKey.toBase58(); + + if (signerPublicKey !== expectedSigner) { + throw new Error( + 'TRANSACTION_FEE_PAYER_KEY does not match the platform payout wallet' + ); + } + + if (transaction.feePayer?.toBase58() !== expectedSigner) { + throw new Error( + 'Payout transaction fee payer does not match the platform' + ); + } + + const requiresSigner = transaction.signatures.some( + ({ publicKey }) => publicKey.toBase58() === expectedSigner + ); + if (!requiresSigner) { + throw new Error('Platform wallet is not a required payout signer'); + } + + transaction.sign(signerKeypair); + + const simulation = await this.WithRetry(() => + this.connection.simulateTransaction(transaction) + ); + if (simulation.value.err) { + const logs = simulation.value.logs?.slice(-3).join(' | '); + throw new Error( + `Payout transaction simulation failed: ${JSON.stringify( + simulation.value.err + )}${logs ? ` (${logs})` : ''}` + ); + } + + return transaction.serialize().toString('base64'); + } + /** * Broadcast a signed transaction to the Solana network. * The transaction must be fully signed before calling this method. diff --git a/apps/api/src/routes/dashboardPayouts.routes.ts b/apps/api/src/routes/dashboardPayouts.routes.ts new file mode 100644 index 0000000..a841bd5 --- /dev/null +++ b/apps/api/src/routes/dashboardPayouts.routes.ts @@ -0,0 +1,61 @@ +/** + * Dashboard-only payout routes. + * + * These routes orchestrate server-side signing without changing the + * Stripe-compatible payout API. + */ + +import * as express from 'express'; +import { CreatePayoutSchema } from '@zoneless/shared-schemas'; +import { + RequireConnectedAccountOwnership, + RequirePlatform, +} from '../middleware/Authorization'; +import { ValidateRequest } from '../middleware/ValidateRequest'; +import { db } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { PayoutModule } from '../modules/Payout'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { Logger } from '../utils/Logger'; + +const router = express.Router(); +const eventService = new EventService(db); +const payoutModule = new PayoutModule(db, eventService); + +/** + * POST /v1/dashboard/payouts + * Create, sign, simulate, and broadcast a connected-account payout. + */ +router.post( + '/', + RequirePlatform(), + RequireConnectedAccountOwnership('zoneless-account', 'header'), + ValidateRequest(CreatePayoutSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const connectedAccountId = req.connectedAccount!.id; + + Logger.info('Processing dashboard payout', { + platformAccountId, + connectedAccountId, + amount: req.body.amount, + }); + + const result = await payoutModule.CreateAndProcessDashboardPayout( + platformAccountId, + connectedAccountId, + req.body + ); + + Logger.info('Dashboard payout processing completed', { + platformAccountId, + connectedAccountId, + status: result.status, + signature: result.signature, + }); + + res.status(201).json(result); + }) +); + +export default router; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index c809e54..5093745 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -34,6 +34,7 @@ import invoicesRouter from './invoices.routes'; import reportingRouter from './reporting.routes'; import billingRouter from './billing.routes'; import telemetryRouter from './telemetry.routes'; +import dashboardPayoutsRouter from './dashboardPayouts.routes'; const router = express.Router(); @@ -89,4 +90,5 @@ router.use('/invoiceitems', invoiceItemsRouter); router.use('/invoices', invoicesRouter); router.use('/reporting', reportingRouter); router.use('/telemetry', telemetryRouter); +router.use('/dashboard/payouts', dashboardPayoutsRouter); export default router; diff --git a/apps/web/src/app/data/services/index.ts b/apps/web/src/app/data/services/index.ts index 374f158..71bff94 100644 --- a/apps/web/src/app/data/services/index.ts +++ b/apps/web/src/app/data/services/index.ts @@ -10,6 +10,7 @@ export * from './invoice.service'; export * from './payment-intent.service'; export * from './payment-link.service'; export * from './person.service'; +export * from './payout.service'; export * from './price.service'; export * from './product.service'; export * from './reporting.service'; diff --git a/apps/web/src/app/data/services/payout.service.ts b/apps/web/src/app/data/services/payout.service.ts new file mode 100644 index 0000000..a156ac6 --- /dev/null +++ b/apps/web/src/app/data/services/payout.service.ts @@ -0,0 +1,29 @@ +import { Injectable, inject } from '@angular/core'; +import type { CreatePayoutInput } from '@zoneless/shared-schemas'; +import type { PayoutBatchBroadcastResponse } from '@zoneless/shared-types'; +import { ApiService } from '../../core'; + +@Injectable({ + providedIn: 'root', +}) +export class PayoutService { + private readonly api = inject(ApiService); + + /** + * Create and process a connected-account payout from the dashboard. + */ + async CreateDashboardPayout( + connectedAccountId: string, + input: CreatePayoutInput + ): Promise { + return this.api.Call( + 'POST', + 'dashboard/payouts', + input, + { + timeout: 60000, + zonelessAccount: connectedAccountId, + } + ); + } +} diff --git a/apps/web/src/app/features/account/connected-accounts/components/add-funds-modal/add-funds-modal.component.scss b/apps/web/src/app/features/account/connected-accounts/components/add-funds-modal/add-funds-modal.component.scss index 6746a66..0892c11 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/add-funds-modal/add-funds-modal.component.scss +++ b/apps/web/src/app/features/account/connected-accounts/components/add-funds-modal/add-funds-modal.component.scss @@ -84,8 +84,3 @@ flex-shrink: 0; } } - -.fund-error { - color: #dc2626; - font-size: $font-size-small; -} diff --git a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html index 2f76886..e0e373a 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html +++ b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.html @@ -3,8 +3,9 @@ title="Pay out to wallet" closeLabel="Cancel" submitLabel="Pay out" - [submitDisabled]="true" - (submitted)="OnSubmit()" + [loading]="actions.payoutLoading()" + [submitDisabled]="!actions.canSubmitPayout()" + (submitted)="actions.ConfirmPayout()" (closed)="actions.ClosePayout()" >
@@ -23,6 +24,7 @@ placeholder="0.00" [value]="actions.payoutAmount()" (input)="OnAmountInput($event)" + [disabled]="actions.payoutLoading()" />
@@ -40,12 +42,13 @@
-
+
Confirm payout
@@ -70,9 +74,25 @@ type="checkbox" [checked]="actions.payoutConfirmed()" (change)="OnConfirmChange($event)" + [disabled]="actions.payoutLoading()" /> {{ confirmLabel() }}
+ + @if (actions.payoutError()) { +
+ {{ actions.payoutError() }} + @if (showFeePayerHelp()) { + + Learn how to configure environment variables. + + } +
+ }
diff --git a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts index 98f8bb1..0fc908b 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts +++ b/apps/web/src/app/features/account/connected-accounts/components/payout-modal/payout-modal.component.ts @@ -32,6 +32,10 @@ export class PayoutModalComponent { this.actions.FormatWalletLabel(this.actions.GetDefaultWallet()) ); + readonly showFeePayerHelp = computed(() => + this.actions.payoutError().includes('TRANSACTION_FEE_PAYER_KEY') + ); + readonly confirmLabel = computed(() => { const amount = this.actions.ParseAmountCents(this.actions.payoutAmount()); const dollars = (amount / 100).toFixed(2); @@ -61,9 +65,4 @@ export class PayoutModalComponent { | 'instant'; this.actions.payoutMethod.set(value); } - - OnSubmit(): void { - // Payout logic is intentionally not implemented yet - this.actions.ClosePayout(); - } } diff --git a/apps/web/src/app/features/account/connected-accounts/components/pull-funds-modal/pull-funds-modal.component.scss b/apps/web/src/app/features/account/connected-accounts/components/pull-funds-modal/pull-funds-modal.component.scss index 5daf26a..dc97bbd 100644 --- a/apps/web/src/app/features/account/connected-accounts/components/pull-funds-modal/pull-funds-modal.component.scss +++ b/apps/web/src/app/features/account/connected-accounts/components/pull-funds-modal/pull-funds-modal.component.scss @@ -40,8 +40,3 @@ flex: 1; min-width: 0; } - -.fund-error { - color: #dc2626; - font-size: $font-size-small; -} diff --git a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts index 5998f06..300c716 100644 --- a/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts +++ b/apps/web/src/app/features/account/connected-accounts/services/connected-account-actions.service.ts @@ -18,8 +18,9 @@ import { AccountService, AccountLinkService, BalanceService, - TransferService, ExternalWalletService, + PayoutService, + TransferService, } from '../../../../data'; import { GetCountryName } from '../../../../utils'; @@ -39,7 +40,8 @@ export type ConnectedAccountActionEvent = } | { type: 'updated'; account: Account } | { type: 'funds_added'; accountId: string } - | { type: 'funds_pulled'; accountId: string }; + | { type: 'funds_pulled'; accountId: string } + | { type: 'payout_processed'; accountId: string }; export interface ConnectedAccountDraft { country: string; @@ -60,6 +62,7 @@ export class ConnectedAccountActionsService { private readonly balanceService = inject(BalanceService); private readonly transferService = inject(TransferService); private readonly externalWalletService = inject(ExternalWalletService); + private readonly payoutService = inject(PayoutService); // ── Create flow ────────────────────────────────────────────────────────── flowOpen: WritableSignal = signal(false); @@ -110,6 +113,8 @@ export class ConnectedAccountActionsService { payoutStatementDescriptor: WritableSignal = signal(''); payoutConfirmed: WritableSignal = signal(false); payoutMethod: WritableSignal<'standard' | 'instant'> = signal('instant'); + payoutLoading: WritableSignal = signal(false); + payoutError: WritableSignal = signal(''); profilePanelOpen: WritableSignal = signal(false); @@ -140,6 +145,18 @@ export class ConnectedAccountActionsService { return amount > 0 && amount <= available && !this.pullFundsLoading(); }); + readonly canSubmitPayout = computed(() => { + const amount = this.ParseAmountCents(this.payoutAmount()); + const available = this.GetAvailableAmount(this.connectedBalance()); + return ( + amount > 0 && + amount <= available && + !!this.GetDefaultWallet() && + this.payoutConfirmed() && + !this.payoutLoading() + ); + }); + readonly events$ = new Subject(); // ── Create flow methods ────────────────────────────────────────────────── @@ -432,14 +449,18 @@ export class ConnectedAccountActionsService { } } - // ── Payout (UI only) ───────────────────────────────────────────────────── + // ── Payout ─────────────────────────────────────────────────────────────── async OpenPayout(account: Account): Promise { this.activeAccount.set(account); this.payoutAmount.set(''); - this.payoutStatementDescriptor.set(''); + this.payoutStatementDescriptor.set( + account.settings?.payouts?.statement_descriptor ?? '' + ); this.payoutConfirmed.set(false); this.payoutMethod.set('instant'); + this.payoutError.set(''); + this.payoutLoading.set(false); try { const [connectedBalance] = await Promise.all([ this.balanceService.GetBalance(account.id), @@ -454,6 +475,53 @@ export class ConnectedAccountActionsService { ClosePayout(): void { this.payoutOpen.set(false); + this.payoutError.set(''); + this.payoutLoading.set(false); + } + + async ConfirmPayout(): Promise { + const account = this.activeAccount(); + const wallet = this.GetDefaultWallet(); + if (!account || !wallet || !this.canSubmitPayout()) return; + + this.payoutLoading.set(true); + this.payoutError.set(''); + + try { + const statementDescriptor = this.payoutStatementDescriptor().trim(); + const result = await this.payoutService.CreateDashboardPayout( + account.id, + { + amount: this.ParseAmountCents(this.payoutAmount()), + currency: 'usdc', + destination: wallet.id, + method: this.payoutMethod(), + ...(statementDescriptor + ? { statement_descriptor: statementDescriptor } + : {}), + } + ); + + this.events$.next({ + type: 'payout_processed', + accountId: account.id, + }); + + if (result.status === 'failed') { + this.payoutError.set( + result.failure_message || 'Failed to process payout.' + ); + return; + } + + this.ClosePayout(); + } catch (err) { + this.payoutError.set( + err instanceof Error ? err.message : 'Failed to process payout.' + ); + } finally { + this.payoutLoading.set(false); + } } // ── Profile panel ──────────────────────────────────────────────────────── diff --git a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts index 893d29a..93755d9 100644 --- a/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts +++ b/apps/web/src/app/features/account/connected-accounts/views/connected-account-detail/connected-account-detail.component.ts @@ -264,7 +264,9 @@ export class ConnectedAccountDetailViewComponent implements OnInit, OnDestroy { this.metaService.SetMetaTitle(this.displayName()); this.sub = this.actions.events$.subscribe((event) => { if ( - (event.type === 'funds_added' || event.type === 'funds_pulled') && + (event.type === 'funds_added' || + event.type === 'funds_pulled' || + event.type === 'payout_processed') && event.accountId === id ) { void this.RefreshBalance(id); diff --git a/apps/web/src/app/styles/forms.scss b/apps/web/src/app/styles/forms.scss index 3408b8a..c5bcfa1 100644 --- a/apps/web/src/app/styles/forms.scss +++ b/apps/web/src/app/styles/forms.scss @@ -28,6 +28,15 @@ font-size: $font-size-small; } +.fund-error { + color: #dc2626; + font-size: $font-size-small; + + a { + font-size: inherit; + } +} + .field-top { display: flex; align-items: center;