Skip to content

Commit 33a8dd5

Browse files
authored
fix: collecting payments on recurring subs (#81)
1 parent 6c4fcef commit 33a8dd5

8 files changed

Lines changed: 241 additions & 31 deletions

File tree

apps/api/src/__tests__/CheckoutPayment.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe('CheckoutPaymentModule', () => {
170170
VerifySubscribeTransaction: jest.fn(),
171171
CollectSubscriptionPayment: jest.fn().mockResolvedValue({
172172
signature: 'collect_sig',
173+
alreadyCollected: false,
173174
}),
174175
CosignAndBroadcastCheckoutTransaction: jest.fn().mockResolvedValue({
175176
signature: 'cosign_sig',

apps/api/src/__tests__/Invoice.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,134 @@ describe('InvoiceModule', () => {
664664
);
665665
expect(result.status).toBe('paid');
666666
});
667+
668+
it('should not mark paid when on-chain period was already collected', async () => {
669+
const existing = DraftInvoice({
670+
status: 'open',
671+
amount_due: 1099,
672+
amount_remaining: 1099,
673+
parent: {
674+
type: 'subscription_details',
675+
subscription_details: {
676+
subscription: 'sub_z_1',
677+
metadata: null,
678+
subscription_proration_date: null,
679+
},
680+
quote_details: null,
681+
},
682+
payments: {
683+
object: 'list',
684+
data: [
685+
{
686+
id: 'inpay_z_1',
687+
object: 'invoice_payment',
688+
amount_paid: null,
689+
amount_requested: 1099,
690+
created: GetFixedTimestamp(),
691+
currency: 'usdc',
692+
invoice: 'in_z_test001',
693+
is_default: true,
694+
livemode: false,
695+
payment: {
696+
charge: null,
697+
payment_intent: 'pi_z_1',
698+
payment_record: null,
699+
type: 'payment_intent',
700+
},
701+
status: 'open',
702+
status_transitions: { canceled_at: null, paid_at: null },
703+
platform_account: PLATFORM,
704+
},
705+
],
706+
has_more: false,
707+
total_count: 1,
708+
url: '/v1/invoices/in_z_test001/payments',
709+
},
710+
});
711+
712+
let invoiceState: Invoice = existing;
713+
714+
const paymentIntentModule = {
715+
MarkSucceeded: jest.fn(),
716+
MarkPaymentFailed: jest.fn().mockResolvedValue({ id: 'pi_z_1' }),
717+
};
718+
const chargeModule = {
719+
CreateFromPaymentAttempt: jest.fn(),
720+
AttachBalanceTransaction: jest.fn(),
721+
};
722+
const solana = {
723+
CollectSubscriptionPayment: jest.fn().mockResolvedValue({
724+
signature: 'already_collected',
725+
alreadyCollected: true,
726+
}),
727+
};
728+
729+
module = new InvoiceModule(
730+
mockDb,
731+
eventService,
732+
customerModule,
733+
invoiceItemModule,
734+
paymentIntentModule as never,
735+
chargeModule as never,
736+
undefined,
737+
solana as never
738+
);
739+
740+
mockDb.Get = jest.fn().mockImplementation(async (collection: string) => {
741+
if (collection === 'Invoices') return invoiceState;
742+
if (collection === 'Subscriptions') {
743+
return {
744+
id: 'sub_z_1',
745+
platform_account: PLATFORM,
746+
subscription_delegation_pda: 'SubPda_1',
747+
default_payment_method: 'Wallet111',
748+
metadata: {},
749+
};
750+
}
751+
if (collection === 'Prices') {
752+
return {
753+
id: 'price_z_1',
754+
platform_account: PLATFORM,
755+
subscription_plan_pda: 'PlanPda_1',
756+
};
757+
}
758+
return null;
759+
}) as typeof mockDb.Get;
760+
761+
mockDb.Find = jest.fn().mockResolvedValue([
762+
{
763+
id: 'si_z_1',
764+
subscription: 'sub_z_1',
765+
price: 'price_z_1',
766+
},
767+
]) as typeof mockDb.Find;
768+
769+
mockDb.Update = jest
770+
.fn()
771+
.mockImplementation(
772+
async (
773+
_collection: string,
774+
_id: string,
775+
updates: Partial<Invoice>
776+
) => {
777+
invoiceState = { ...invoiceState, ...updates };
778+
return invoiceState;
779+
}
780+
) as typeof mockDb.Update;
781+
782+
const result = await module.PayInvoice(existing.id, {});
783+
784+
expect(solana.CollectSubscriptionPayment).toHaveBeenCalled();
785+
expect(chargeModule.CreateFromPaymentAttempt).not.toHaveBeenCalled();
786+
expect(paymentIntentModule.MarkSucceeded).not.toHaveBeenCalled();
787+
expect(result.status).not.toBe('paid');
788+
expect(result.attempted).toBe(true);
789+
expect(eventService.Emit).toHaveBeenCalledWith(
790+
'invoice.payment_failed',
791+
PLATFORM,
792+
expect.anything()
793+
);
794+
});
667795
});
668796

669797
describe('VoidInvoice', () => {

apps/api/src/__tests__/mocks/Solana.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export class Solana {
5757
});
5858
CollectSubscriptionPayment = jest.fn().mockResolvedValue({
5959
signature: 'collect_sig',
60+
alreadyCollected: false,
6061
});
6162
FindExistingSubscriptionDelegation = jest.fn().mockResolvedValue(null);
6263
CosignAndBroadcastCheckoutTransaction = jest.fn().mockResolvedValue({

apps/api/src/modules/Invoice.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ const RETRY_DELAYS_SECONDS = [
8282
5 * SECONDS_PER_DAY,
8383
];
8484

85+
/**
86+
* When Solana rejects because this period's allowance is already used, the
87+
* next billing run should be allowed to retry immediately (period may have
88+
* rolled). A multi-minute backoff made manual catch-up look permanently stuck
89+
* on `skipped`.
90+
*/
91+
const PERIOD_ALLOWANCE_RETRY_SECONDS = 0;
92+
93+
const PERIOD_ALLOWANCE_ALREADY_COLLECTED =
94+
/period allowance already collected|exceeds period limit/i;
95+
8596
export class InvoiceModule {
8697
private readonly db: Database;
8798
private readonly eventService: EventService | null;
@@ -1556,11 +1567,14 @@ export class InvoiceModule {
15561567
message: string
15571568
): Promise<InvoiceType> {
15581569
const now = Now();
1559-
const attemptCount = previous.attempt_count + 1;
1560-
const nextPaymentAttempt = this.ComputeNextPaymentAttempt(
1561-
attemptCount,
1562-
now
1563-
);
1570+
const isPeriodAllowance = PERIOD_ALLOWANCE_ALREADY_COLLECTED.test(message);
1571+
// Period-limit waits are not payment declines — don't burn retry budget.
1572+
const attemptCount = isPeriodAllowance
1573+
? previous.attempt_count
1574+
: previous.attempt_count + 1;
1575+
const nextPaymentAttempt = isPeriodAllowance
1576+
? now + PERIOD_ALLOWANCE_RETRY_SECONDS
1577+
: this.ComputeNextPaymentAttempt(attemptCount, now);
15641578

15651579
await this.db.Update<InvoiceType>('Invoices', previous.id, {
15661580
attempted: true,
@@ -1671,6 +1685,21 @@ export class InvoiceModule {
16711685
amountCents,
16721686
});
16731687

1688+
// On-chain plans allow one pull per periodHours. Skipped Stripe cycles do
1689+
// not stack — if we already pulled this Solana period, no USDC moves.
1690+
// Treating that as success was marking invoices paid / advancing periods
1691+
// with no wallet debit.
1692+
if (collection.alreadyCollected) {
1693+
Logger.warn('On-chain subscription period already collected', {
1694+
invoiceId: invoice.id,
1695+
subscriptionId,
1696+
amountCents,
1697+
});
1698+
throw new Error(
1699+
'On-chain subscription period allowance already collected; wait for the next Solana billing period before collecting again'
1700+
);
1701+
}
1702+
16741703
return {
16751704
signature: collection.signature,
16761705
subscriberWallet,

apps/api/src/modules/Subscription.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,19 +1356,46 @@ export class SubscriptionModule {
13561356
? subscription.customer
13571357
: subscription.customer.id;
13581358

1359-
const lineItems = items.map((item) => {
1359+
const lineItems: Array<{
1360+
price: string;
1361+
quantity: number;
1362+
period: { start: number; end: number };
1363+
subscription_item: string;
1364+
}> = [];
1365+
for (const item of items) {
13601366
const priceId =
13611367
typeof item.price === 'string' ? item.price : item.price.id;
1362-
return {
1368+
1369+
// Renewals bill the UPCOMING period in advance (Stripe semantics):
1370+
// [current_period_end, current_period_end + interval]. Stamping the
1371+
// elapsed period here would duplicate the previous invoice's period
1372+
// (the subscription_create invoice already billed the first period).
1373+
let period = {
1374+
start: item.current_period_start,
1375+
end: item.current_period_end,
1376+
};
1377+
if (billingReason === 'subscription_cycle') {
1378+
const price = await this.ResolvePrice(platformAccountId, {
1379+
price: priceId,
1380+
} as CreateItemInput);
1381+
const start = item.current_period_end;
1382+
period = {
1383+
start,
1384+
end: AddRecurringInterval(
1385+
start,
1386+
price.recurring?.interval ?? 'month',
1387+
price.recurring?.interval_count ?? 1
1388+
),
1389+
};
1390+
}
1391+
1392+
lineItems.push({
13631393
price: priceId,
13641394
quantity: item.quantity ?? 1,
1365-
period: {
1366-
start: item.current_period_start,
1367-
end: item.current_period_end,
1368-
},
1395+
period,
13691396
subscription_item: item.id,
1370-
};
1371-
});
1397+
});
1398+
}
13721399

13731400
return this.invoiceModule.CreateSubscriptionInvoice(platformAccountId, {
13741401
customer: customerId,
@@ -1410,6 +1437,8 @@ export class SubscriptionModule {
14101437
/**
14111438
* Advance each subscription item into the next billing period after a
14121439
* successful cycle payment. Clears the billing lock and sets status active.
1440+
* Advances one interval at a time so a merchant who was offline can keep
1441+
* collecting once per on-chain period until the Stripe-side ledger catches up.
14131442
*/
14141443
async AdvanceSubscriptionPeriod(
14151444
subscriptionId: string,
@@ -1433,9 +1462,15 @@ export class SubscriptionModule {
14331462
});
14341463
}
14351464

1465+
// Keep the denormalized items snapshot on the subscription document in
1466+
// sync — mongo explorers (and any direct reads) otherwise show the
1467+
// create-time periods forever even though SubscriptionItems advanced.
1468+
const refreshedItems = await this.LoadItemsList(subscriptionId);
1469+
14361470
return this.UpdateSubscriptionBillingState(subscription, {
14371471
status: 'active',
14381472
latestInvoiceId,
1473+
items: refreshedItems,
14391474
});
14401475
}
14411476

@@ -1472,6 +1507,7 @@ export class SubscriptionModule {
14721507
options: {
14731508
status: SubscriptionStatus;
14741509
latestInvoiceId?: string;
1510+
items?: SubscriptionType['items'];
14751511
}
14761512
): Promise<SubscriptionType> {
14771513
const updatePayload: Partial<SubscriptionType> = {
@@ -1481,6 +1517,9 @@ export class SubscriptionModule {
14811517
if (options.latestInvoiceId) {
14821518
updatePayload.latest_invoice = options.latestInvoiceId;
14831519
}
1520+
if (options.items) {
1521+
updatePayload.items = options.items;
1522+
}
14841523

14851524
await this.db.Update<SubscriptionType>(
14861525
'Subscriptions',

apps/api/src/modules/SubscriptionBilling.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export class SubscriptionBillingModule {
122122
errors: [],
123123
};
124124

125+
// Each subscription gets at most one attempt per run — a retry failure
126+
// (e.g. on-chain allowance used) shouldn't be attempted again seconds
127+
// later by the due-subscriptions pass.
128+
const attempted = new Set<string>();
129+
125130
const retryInvoices = await this.FindRetryInvoices(
126131
now,
127132
options.platformAccountId,
@@ -132,10 +137,11 @@ export class SubscriptionBillingModule {
132137
const subscriptionId = ExpandableId(
133138
invoice.parent?.subscription_details?.subscription
134139
);
135-
if (!subscriptionId) {
140+
if (!subscriptionId || attempted.has(subscriptionId)) {
136141
result.skipped += 1;
137142
continue;
138143
}
144+
attempted.add(subscriptionId);
139145
await this.WithBillingClaim(subscriptionId, result, async (claimed) => {
140146
const paid = await this.invoiceModule.PayInvoice(invoice.id);
141147
await this.HandleInvoiceOutcome(claimed, paid, result);
@@ -155,6 +161,8 @@ export class SubscriptionBillingModule {
155161

156162
for (const subscriptionId of dueSubscriptionIds) {
157163
if (result.processed >= batchSize) break;
164+
if (attempted.has(subscriptionId)) continue;
165+
attempted.add(subscriptionId);
158166
await this.WithBillingClaim(subscriptionId, result, async (claimed) => {
159167
await this.CollectOrCreateCycleInvoice(claimed, result);
160168
});

0 commit comments

Comments
 (0)