Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/api/src/__tests__/CheckoutPayment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
128 changes: 128 additions & 0 deletions apps/api/src/__tests__/Invoice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Invoice>
) => {
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', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/__tests__/mocks/Solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
39 changes: 34 additions & 5 deletions apps/api/src/modules/Invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1556,11 +1567,14 @@ export class InvoiceModule {
message: string
): Promise<InvoiceType> {
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<InvoiceType>('Invoices', previous.id, {
attempted: true,
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 47 additions & 8 deletions apps/api/src/modules/Subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -1472,6 +1507,7 @@ export class SubscriptionModule {
options: {
status: SubscriptionStatus;
latestInvoiceId?: string;
items?: SubscriptionType['items'];
}
): Promise<SubscriptionType> {
const updatePayload: Partial<SubscriptionType> = {
Expand All @@ -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<SubscriptionType>(
'Subscriptions',
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/modules/SubscriptionBilling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

const retryInvoices = await this.FindRetryInvoices(
now,
options.platformAccountId,
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down
Loading
Loading