diff --git a/apps/api/src/__tests__/CheckoutPayment.spec.ts b/apps/api/src/__tests__/CheckoutPayment.spec.ts index a0b3d69..9c4e416 100644 --- a/apps/api/src/__tests__/CheckoutPayment.spec.ts +++ b/apps/api/src/__tests__/CheckoutPayment.spec.ts @@ -170,6 +170,7 @@ describe('CheckoutPaymentModule', () => { VerifySubscribeTransaction: jest.fn(), CollectSubscriptionPayment: jest.fn().mockResolvedValue({ signature: 'collect_sig', + alreadyCollected: false, }), CosignAndBroadcastCheckoutTransaction: jest.fn().mockResolvedValue({ signature: 'cosign_sig', diff --git a/apps/api/src/__tests__/Invoice.spec.ts b/apps/api/src/__tests__/Invoice.spec.ts index fd80f29..db2bf5b 100644 --- a/apps/api/src/__tests__/Invoice.spec.ts +++ b/apps/api/src/__tests__/Invoice.spec.ts @@ -664,6 +664,134 @@ describe('InvoiceModule', () => { ); expect(result.status).toBe('paid'); }); + + it('should not mark paid when on-chain period was already collected', async () => { + const existing = DraftInvoice({ + status: 'open', + amount_due: 1099, + amount_remaining: 1099, + parent: { + type: 'subscription_details', + subscription_details: { + subscription: 'sub_z_1', + metadata: null, + subscription_proration_date: null, + }, + quote_details: null, + }, + payments: { + object: 'list', + data: [ + { + id: 'inpay_z_1', + object: 'invoice_payment', + amount_paid: null, + amount_requested: 1099, + created: GetFixedTimestamp(), + currency: 'usdc', + invoice: 'in_z_test001', + is_default: true, + livemode: false, + payment: { + charge: null, + payment_intent: 'pi_z_1', + payment_record: null, + type: 'payment_intent', + }, + status: 'open', + status_transitions: { canceled_at: null, paid_at: null }, + platform_account: PLATFORM, + }, + ], + has_more: false, + total_count: 1, + url: '/v1/invoices/in_z_test001/payments', + }, + }); + + let invoiceState: Invoice = existing; + + const paymentIntentModule = { + MarkSucceeded: jest.fn(), + MarkPaymentFailed: jest.fn().mockResolvedValue({ id: 'pi_z_1' }), + }; + const chargeModule = { + CreateFromPaymentAttempt: jest.fn(), + AttachBalanceTransaction: jest.fn(), + }; + const solana = { + CollectSubscriptionPayment: jest.fn().mockResolvedValue({ + signature: 'already_collected', + alreadyCollected: true, + }), + }; + + module = new InvoiceModule( + mockDb, + eventService, + customerModule, + invoiceItemModule, + paymentIntentModule as never, + chargeModule as never, + undefined, + solana as never + ); + + mockDb.Get = jest.fn().mockImplementation(async (collection: string) => { + if (collection === 'Invoices') return invoiceState; + if (collection === 'Subscriptions') { + return { + id: 'sub_z_1', + platform_account: PLATFORM, + subscription_delegation_pda: 'SubPda_1', + default_payment_method: 'Wallet111', + metadata: {}, + }; + } + if (collection === 'Prices') { + return { + id: 'price_z_1', + platform_account: PLATFORM, + subscription_plan_pda: 'PlanPda_1', + }; + } + return null; + }) as typeof mockDb.Get; + + mockDb.Find = jest.fn().mockResolvedValue([ + { + id: 'si_z_1', + subscription: 'sub_z_1', + price: 'price_z_1', + }, + ]) as typeof mockDb.Find; + + mockDb.Update = jest + .fn() + .mockImplementation( + async ( + _collection: string, + _id: string, + updates: Partial + ) => { + invoiceState = { ...invoiceState, ...updates }; + return invoiceState; + } + ) as typeof mockDb.Update; + + const result = await module.PayInvoice(existing.id, {}); + + expect(solana.CollectSubscriptionPayment).toHaveBeenCalled(); + expect(chargeModule.CreateFromPaymentAttempt).not.toHaveBeenCalled(); + expect(paymentIntentModule.MarkSucceeded).not.toHaveBeenCalled(); + expect(result.status).not.toBe('paid'); + expect(result.attempted).toBe(true); + expect(eventService.Emit).toHaveBeenCalledWith( + 'invoice.payment_failed', + PLATFORM, + expect.anything() + ); + }); }); describe('VoidInvoice', () => { diff --git a/apps/api/src/__tests__/mocks/Solana.ts b/apps/api/src/__tests__/mocks/Solana.ts index f1d7c90..06e5091 100644 --- a/apps/api/src/__tests__/mocks/Solana.ts +++ b/apps/api/src/__tests__/mocks/Solana.ts @@ -57,6 +57,7 @@ export class Solana { }); CollectSubscriptionPayment = jest.fn().mockResolvedValue({ signature: 'collect_sig', + alreadyCollected: false, }); FindExistingSubscriptionDelegation = jest.fn().mockResolvedValue(null); CosignAndBroadcastCheckoutTransaction = jest.fn().mockResolvedValue({ diff --git a/apps/api/src/modules/Invoice.ts b/apps/api/src/modules/Invoice.ts index e977612..4c2ddff 100644 --- a/apps/api/src/modules/Invoice.ts +++ b/apps/api/src/modules/Invoice.ts @@ -82,6 +82,17 @@ const RETRY_DELAYS_SECONDS = [ 5 * SECONDS_PER_DAY, ]; +/** + * When Solana rejects because this period's allowance is already used, the + * next billing run should be allowed to retry immediately (period may have + * rolled). A multi-minute backoff made manual catch-up look permanently stuck + * on `skipped`. + */ +const PERIOD_ALLOWANCE_RETRY_SECONDS = 0; + +const PERIOD_ALLOWANCE_ALREADY_COLLECTED = + /period allowance already collected|exceeds period limit/i; + export class InvoiceModule { private readonly db: Database; private readonly eventService: EventService | null; @@ -1556,11 +1567,14 @@ export class InvoiceModule { message: string ): Promise { const now = Now(); - const attemptCount = previous.attempt_count + 1; - const nextPaymentAttempt = this.ComputeNextPaymentAttempt( - attemptCount, - now - ); + const isPeriodAllowance = PERIOD_ALLOWANCE_ALREADY_COLLECTED.test(message); + // Period-limit waits are not payment declines — don't burn retry budget. + const attemptCount = isPeriodAllowance + ? previous.attempt_count + : previous.attempt_count + 1; + const nextPaymentAttempt = isPeriodAllowance + ? now + PERIOD_ALLOWANCE_RETRY_SECONDS + : this.ComputeNextPaymentAttempt(attemptCount, now); await this.db.Update('Invoices', previous.id, { attempted: true, @@ -1671,6 +1685,21 @@ export class InvoiceModule { amountCents, }); + // On-chain plans allow one pull per periodHours. Skipped Stripe cycles do + // not stack — if we already pulled this Solana period, no USDC moves. + // Treating that as success was marking invoices paid / advancing periods + // with no wallet debit. + if (collection.alreadyCollected) { + Logger.warn('On-chain subscription period already collected', { + invoiceId: invoice.id, + subscriptionId, + amountCents, + }); + throw new Error( + 'On-chain subscription period allowance already collected; wait for the next Solana billing period before collecting again' + ); + } + return { signature: collection.signature, subscriberWallet, diff --git a/apps/api/src/modules/Subscription.ts b/apps/api/src/modules/Subscription.ts index 58d6771..5814a81 100644 --- a/apps/api/src/modules/Subscription.ts +++ b/apps/api/src/modules/Subscription.ts @@ -1356,19 +1356,46 @@ export class SubscriptionModule { ? subscription.customer : subscription.customer.id; - const lineItems = items.map((item) => { + const lineItems: Array<{ + price: string; + quantity: number; + period: { start: number; end: number }; + subscription_item: string; + }> = []; + for (const item of items) { const priceId = typeof item.price === 'string' ? item.price : item.price.id; - return { + + // Renewals bill the UPCOMING period in advance (Stripe semantics): + // [current_period_end, current_period_end + interval]. Stamping the + // elapsed period here would duplicate the previous invoice's period + // (the subscription_create invoice already billed the first period). + let period = { + start: item.current_period_start, + end: item.current_period_end, + }; + if (billingReason === 'subscription_cycle') { + const price = await this.ResolvePrice(platformAccountId, { + price: priceId, + } as CreateItemInput); + const start = item.current_period_end; + period = { + start, + end: AddRecurringInterval( + start, + price.recurring?.interval ?? 'month', + price.recurring?.interval_count ?? 1 + ), + }; + } + + lineItems.push({ price: priceId, quantity: item.quantity ?? 1, - period: { - start: item.current_period_start, - end: item.current_period_end, - }, + period, subscription_item: item.id, - }; - }); + }); + } return this.invoiceModule.CreateSubscriptionInvoice(platformAccountId, { customer: customerId, @@ -1410,6 +1437,8 @@ export class SubscriptionModule { /** * Advance each subscription item into the next billing period after a * successful cycle payment. Clears the billing lock and sets status active. + * Advances one interval at a time so a merchant who was offline can keep + * collecting once per on-chain period until the Stripe-side ledger catches up. */ async AdvanceSubscriptionPeriod( subscriptionId: string, @@ -1433,9 +1462,15 @@ export class SubscriptionModule { }); } + // Keep the denormalized items snapshot on the subscription document in + // sync — mongo explorers (and any direct reads) otherwise show the + // create-time periods forever even though SubscriptionItems advanced. + const refreshedItems = await this.LoadItemsList(subscriptionId); + return this.UpdateSubscriptionBillingState(subscription, { status: 'active', latestInvoiceId, + items: refreshedItems, }); } @@ -1472,6 +1507,7 @@ export class SubscriptionModule { options: { status: SubscriptionStatus; latestInvoiceId?: string; + items?: SubscriptionType['items']; } ): Promise { const updatePayload: Partial = { @@ -1481,6 +1517,9 @@ export class SubscriptionModule { if (options.latestInvoiceId) { updatePayload.latest_invoice = options.latestInvoiceId; } + if (options.items) { + updatePayload.items = options.items; + } await this.db.Update( 'Subscriptions', diff --git a/apps/api/src/modules/SubscriptionBilling.ts b/apps/api/src/modules/SubscriptionBilling.ts index a5affc7..771ea38 100644 --- a/apps/api/src/modules/SubscriptionBilling.ts +++ b/apps/api/src/modules/SubscriptionBilling.ts @@ -122,6 +122,11 @@ export class SubscriptionBillingModule { errors: [], }; + // Each subscription gets at most one attempt per run — a retry failure + // (e.g. on-chain allowance used) shouldn't be attempted again seconds + // later by the due-subscriptions pass. + const attempted = new Set(); + const retryInvoices = await this.FindRetryInvoices( now, options.platformAccountId, @@ -132,10 +137,11 @@ export class SubscriptionBillingModule { const subscriptionId = ExpandableId( invoice.parent?.subscription_details?.subscription ); - if (!subscriptionId) { + if (!subscriptionId || attempted.has(subscriptionId)) { result.skipped += 1; continue; } + attempted.add(subscriptionId); await this.WithBillingClaim(subscriptionId, result, async (claimed) => { const paid = await this.invoiceModule.PayInvoice(invoice.id); await this.HandleInvoiceOutcome(claimed, paid, result); @@ -155,6 +161,8 @@ export class SubscriptionBillingModule { for (const subscriptionId of dueSubscriptionIds) { if (result.processed >= batchSize) break; + if (attempted.has(subscriptionId)) continue; + attempted.add(subscriptionId); await this.WithBillingClaim(subscriptionId, result, async (claimed) => { await this.CollectOrCreateCycleInvoice(claimed, result); }); diff --git a/apps/api/src/modules/chains/Solana.ts b/apps/api/src/modules/chains/Solana.ts index cbd2a23..eb0c70a 100644 --- a/apps/api/src/modules/chains/Solana.ts +++ b/apps/api/src/modules/chains/Solana.ts @@ -543,7 +543,11 @@ export class Solana { signature: string ): Promise { return this.WithRetry(async () => { + // Match WaitForSignatureConfirmation: default Connection commitment is + // `finalized`, which lags `confirmed` and caused subscribe confirm to + // time out on the first attempt ("Transaction not found on-chain"). return this.connection.getParsedTransaction(signature, { + commitment: 'confirmed', maxSupportedTransactionVersion: 0, }); }); @@ -971,7 +975,7 @@ export class Solana { */ async WaitForSubscriptionAuthority( subscriberWallet: string, - maxAttempts = 20 + maxAttempts = CONFIRM_MAX_ATTEMPTS ): Promise { const tokenMint = address(this.GetUSDCMintAddress()); const subscriberAddress = address(subscriberWallet); @@ -1070,9 +1074,8 @@ export class Solana { subscriberWallet: string; } ): Promise { - const maxAttempts = 20; let tx: ParsedTransactionWithMeta | null = null; - for (let attempt = 0; attempt < maxAttempts; attempt++) { + for (let attempt = 0; attempt < CONFIRM_MAX_ATTEMPTS; attempt++) { tx = await this.GetParsedTransaction(signature); if (tx) break; await Sleep(CONFIRM_POLL_MS); @@ -1168,7 +1171,7 @@ export class Solana { amountCents: number; /** Optional; must match a plan destination if the plan has an allowlist. */ destinationWallet?: string; - }): Promise<{ signature: string }> { + }): Promise<{ signature: string; alreadyCollected: boolean }> { const { subscriberWallet, planPda, @@ -1273,14 +1276,11 @@ export class Solana { const amount = BigInt(amountCents * 10_000); - const existingSub = await fetchMaybeSubscriptionDelegation( - rpcClient.rpc, - address(subscriptionPda) - ); - if (existingSub.exists && existingSub.data.amountPulledInPeriod >= amount) { - // Already collected this billing period (e.g. retry after a partial confirm). - return { signature: 'already_collected' }; - } + // Do NOT short-circuit on amountPulledInPeriod alone. That counter is only + // reset when transferSubscription runs (after the program advances + // currentPeriodStartTs). Reading a stale pull count after the period has + // rolled would permanently block renewals with a false "already collected". + // Always attempt the transfer; #400 means we are still inside the period. try { const result = await rpcClient.subscriptions.instructions @@ -1296,11 +1296,14 @@ export class Solana { }) .sendTransaction(); - return { signature: result.context.signature }; + return { + signature: result.context.signature, + alreadyCollected: false, + }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); if (/#400|exceeds period limit/i.test(message)) { - return { signature: 'already_collected' }; + return { signature: 'already_collected', alreadyCollected: true }; } throw new Error( `Subscription collect failed (destination ${resolvedDestination}, ATA ${receiverAta.toBase58()}): ${message}` @@ -1417,9 +1420,8 @@ export class Solana { const merchantTokenAccountStr = merchantTokenAccount.toBase58(); // Poll for the transaction to reach 'confirmed' commitment - const maxAttempts = 20; let tx: ParsedTransactionWithMeta | null = null; - for (let attempt = 0; attempt < maxAttempts; attempt++) { + for (let attempt = 0; attempt < CONFIRM_MAX_ATTEMPTS; attempt++) { tx = await this.GetParsedTransaction(signature); if (tx) break; await Sleep(CONFIRM_POLL_MS); diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts index 85f558d..3d9a6fb 100644 --- a/apps/web/src/app/features/checkout/checkout.component.ts +++ b/apps/web/src/app/features/checkout/checkout.component.ts @@ -252,7 +252,9 @@ export class CheckoutComponent implements OnInit { : typeof error === 'string' ? error : ''; - return /blockhash not found|expired|already been processed/i.test(message); + return /blockhash not found|expired|already been processed|not found on-chain|may not be confirmed yet/i.test( + message + ); } private RedirectToSuccessUrl(session: CheckoutSession): void {