Skip to content
Closed
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
38 changes: 36 additions & 2 deletions apps/web/src/lib/autoTopUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jest.mock('@/lib/stripe-client', () => {
client: {
invoices: {
create: jest.fn(function (this: void) {}),
update: jest.fn(function (this: void) {}),
pay: jest.fn(function (this: void) {}),
},
invoiceItems: {
Expand Down Expand Up @@ -639,7 +640,14 @@ describe('maybePerformAutoTopUp with Kilo Pass', () => {
});

const { client } = await import('@/lib/stripe-client');
(client.invoices.create as jest.Mock).mockResolvedValue({ id: 'inv_test_1' });
(client.invoices.create as jest.Mock).mockResolvedValue({
id: 'inv_test_1',
created: 1_700_000_000,
});
(client.invoices.update as jest.Mock).mockResolvedValue({
id: 'inv_test_1',
created: 1_700_000_000,
});
(client.invoiceItems.create as jest.Mock).mockResolvedValue({ id: 'ii_test_1' });
(client.invoices.pay as jest.Mock).mockResolvedValue({ status: 'paid' });

Expand Down Expand Up @@ -673,7 +681,14 @@ describe('invoice metadata includes traceId', () => {
});

const { client } = await import('@/lib/stripe-client');
(client.invoices.create as jest.Mock).mockResolvedValue({ id: 'inv_trace_test' });
(client.invoices.create as jest.Mock).mockResolvedValue({
id: 'inv_trace_test',
created: 1_700_000_000,
});
(client.invoices.update as jest.Mock).mockResolvedValue({
id: 'inv_trace_test',
created: 1_700_000_000,
});
(client.invoiceItems.create as jest.Mock).mockResolvedValue({ id: 'ii_trace_test' });
(client.invoices.pay as jest.Mock).mockResolvedValue({ id: 'inv_trace_test', status: 'paid' });

Expand All @@ -687,9 +702,28 @@ describe('invoice metadata includes traceId', () => {
expect.objectContaining({
metadata: expect.objectContaining({
traceId: expect.stringMatching(uuidPattern),
type: 'auto-topup',
serviceFeePrincipalMinor: '5000',
serviceFeeFlow: 'personal_auto_top_up',
}),
})
);
expect(client.invoices.update).toHaveBeenCalledWith(
'inv_trace_test',
expect.objectContaining({
metadata: expect.objectContaining({
serviceFeePrincipalMinor: '5000',
serviceFeeAssessmentKey: expect.stringMatching(/^invoice:inv_trace_test$/),
}),
})
);
expect(client.invoiceItems.create).toHaveBeenCalledTimes(1);
expect(client.invoiceItems.create).toHaveBeenCalledWith(
expect.objectContaining({
amount: 5000,
description: 'Kilo automatic top up',
})
);
});
});

Expand Down
82 changes: 65 additions & 17 deletions apps/web/src/lib/autoTopUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ import {
ORG_AUTO_TOP_UP_THRESHOLD_DOLLARS,
DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS,
} from '@/lib/autoTopUpConstants';
import {
attachPreparedAutoTopUpInvoiceFee,
mergeServiceFeeCommercialMetadata,
prepareAutoTopUpInvoiceFee,
type ServiceFeeCheckoutDependencies,
} from '@/lib/service-fees/checkout';
import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store';
import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions';

function createAutoTopUpFeeDeps(): ServiceFeeCheckoutDependencies {
const stores = createServiceFeeStores();
return {
store: stores.assessments,
findEffectiveExemption: async (organizationId, at) =>
getEffectiveOrganizationServiceFeeExemption({
store: stores.exemptions,
organizationId,
at,
}),
stripe: client,
createInvoiceItem: params => client.invoiceItems.create(params),
};
}

const ATTEMPT_LOCK_TIMEOUT_SECONDS = 60 * 60 * 2; // 2 hours (covers delayed webhook delivery)

Expand Down Expand Up @@ -261,30 +284,51 @@ async function performAutoTopUpForEntity(
// Credit application is handled by the `invoice.paid` webhook.
const invoiceMetadata: Record<string, string> =
entity.type === 'user'
? {
type: 'auto-topup',
kiloUserId: entity.user.id,
traceId,
amountCents: String(amountCents),
serviceFeePrincipalMinor: String(amountCents),
serviceFeeFlow: 'personal_auto_top_up',
}
: {
type: 'org-auto-topup',
organizationId: entity.organization.id,
traceId,
amountCents: String(amountCents),
serviceFeePrincipalMinor: String(amountCents),
serviceFeeFlow: 'organization_auto_top_up',
};
? { type: 'auto-topup', kiloUserId: entity.user.id, traceId }
: { type: 'org-auto-topup', organizationId: entity.organization.id, traceId };

const flow = entity.type === 'user' ? 'personal_auto_top_up' : 'organization_auto_top_up';
const invoice = await client.invoices.create({
customer: stripe_customer_id,
auto_advance: false,
metadata: invoiceMetadata,
metadata: {
...invoiceMetadata,
serviceFeePrincipalMinor: String(amountCents),
serviceFeeFlow: flow,
},
description: 'Kilo automatic top up',
});

const feeDeps = createAutoTopUpFeeDeps();
const preparedFee = await prepareAutoTopUpInvoiceFee({
flow,
invoiceId: invoice.id,
principalMinor: amountCents,
kiloUserId: entity.type === 'user' ? entity.user.id : undefined,
organizationId: entity.type === 'organization' ? entity.organization.id : undefined,
stripeCustomerId: stripe_customer_id,
invoiceCreated: invoice.created ? new Date(invoice.created * 1000) : undefined,
taxPrincipal: { kind: 'inline' },
deps: feeDeps,
});

try {
await client.invoices.update(invoice.id, {
metadata: mergeServiceFeeCommercialMetadata(
{
...invoiceMetadata,
serviceFeePrincipalMinor: String(amountCents),
},
preparedFee.commercialMetadata
),
});
} catch (metadataError) {
captureException(metadataError, {
tags: { source: 'auto_top_up_service_fee_metadata' },
extra: { invoice_id: invoice.id, flow },
});
}

// Attach the line item directly to this invoice.
// (Creating a pending invoice item and then creating an invoice can produce a $0 invoice,
// depending on Stripe's pending_invoice_items_behavior defaults.)
Expand All @@ -295,6 +339,10 @@ async function performAutoTopUpForEntity(
currency: 'usd',
description: 'Kilo automatic top up',
});
await attachPreparedAutoTopUpInvoiceFee({
prepared: preparedFee,
deps: feeDeps,
});

// Pay the invoice. The PaymentIntent is created during payment, not finalization.
const paidInvoice = await client.invoices.pay(invoice.id, {
Expand Down
119 changes: 84 additions & 35 deletions apps/web/src/lib/organizations/organization-auto-top-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
DEFAULT_ORG_AUTO_TOP_UP_AMOUNT_CENTS,
} from '@/lib/autoTopUpConstants';
import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags';
import {
createTopUpCheckoutSession,
mergeServiceFeeCommercialMetadata,
prepareTopUpCheckoutFee,
type ServiceFeeCheckoutDependencies,
} from '@/lib/service-fees/checkout';
import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store';
import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions';

export async function isOrgAutoTopUpFeatureEnabled(organizationId: string): Promise<boolean> {
return (
Expand All @@ -14,6 +22,23 @@ export async function isOrgAutoTopUpFeatureEnabled(organizationId: string): Prom
);
}

function createOrgAutoTopUpFeeDeps(): ServiceFeeCheckoutDependencies {
const stores = createServiceFeeStores();
return {
store: stores.assessments,
findEffectiveExemption: async (organizationId, at) =>
getEffectiveOrganizationServiceFeeExemption({
store: stores.exemptions,
organizationId,
at,
}),
stripe: client,
listCheckoutLineItems: (sessionId, params) =>
client.checkout.sessions.listLineItems(sessionId, params),
expireCheckoutSession: sessionId => client.checkout.sessions.expire(sessionId),
};
}

/**
* Creates a Stripe checkout session for organization auto-top-up setup.
* Similar to user auto-top-up but with organization metadata.
Expand All @@ -25,46 +50,70 @@ export async function createOrgAutoTopUpSetupCheckoutSession(
amountCents: number = DEFAULT_ORG_AUTO_TOP_UP_AMOUNT_CENTS
): Promise<string | null> {
const amountDollars = amountCents / 100;
const feeDeps = createOrgAutoTopUpFeeDeps();
const prepared = await prepareTopUpCheckoutFee({
flow: 'organization_auto_top_up_setup',
principalMinor: amountCents,
kiloUserId,
organizationId,
stripeCustomerId,
taxPrincipal: { kind: 'inline' },
deps: feeDeps,
});

const checkoutSession = await client.checkout.sessions.create({
mode: 'payment',
customer: stripeCustomerId,
billing_address_collection: 'required',
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Organization Credit Top-Up with Auto-Refill Setup',
description: `Initial $${amountDollars} top-up. Your card will be saved for automatic $${amountDollars} top ups when balance drops below $${ORG_AUTO_TOP_UP_THRESHOLD_DOLLARS}.`,
const checkoutSession = await createTopUpCheckoutSession({
prepared,
buildSessionParams: feeLine => ({
mode: 'payment',
customer: stripeCustomerId,
billing_address_collection: 'required',
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Organization Credit Top-Up with Auto-Refill Setup',
description: `Initial $${amountDollars} top-up. Your card will be saved for automatic $${amountDollars} top ups when balance drops below $${ORG_AUTO_TOP_UP_THRESHOLD_DOLLARS}.`,
},
unit_amount: amountCents,
},
unit_amount: amountCents,
quantity: 1,
},
quantity: 1,
...(feeLine ? [feeLine] : []),
],
invoice_creation: {
enabled: true,
},
],
invoice_creation: {
enabled: true,
},
customer_update: {
name: 'auto',
address: 'auto',
},
tax_id_collection: {
enabled: true,
required: 'never',
},
success_url: `${APP_URL}/organizations/${organizationId}/payment-details?auto_topup_setup=success`,
cancel_url: `${APP_URL}/organizations/${organizationId}/payment-details?auto_topup_setup=cancelled`,
payment_intent_data: {
metadata: {
type: 'org-auto-topup-setup',
kiloUserId,
organizationId,
amountCents: String(amountCents),
customer_update: {
name: 'auto',
address: 'auto',
},
tax_id_collection: {
enabled: true,
required: 'never',
},
success_url: `${APP_URL}/organizations/${organizationId}/payment-details?auto_topup_setup=success`,
cancel_url: `${APP_URL}/organizations/${organizationId}/payment-details?auto_topup_setup=cancelled`,
metadata: mergeServiceFeeCommercialMetadata(
{ type: 'org-auto-topup-setup', kiloUserId, organizationId },
prepared.commercialMetadata
),
payment_intent_data: {
metadata: mergeServiceFeeCommercialMetadata(
{
type: 'org-auto-topup-setup',
kiloUserId,
organizationId,
amountCents: String(amountCents),
},
prepared.commercialMetadata
),
setup_future_usage: 'off_session',
},
setup_future_usage: 'off_session',
},
expand: ['line_items.data.price.product'],
}),
createSession: params => client.checkout.sessions.create(params),
deps: feeDeps,
});

return typeof checkoutSession.url === 'string' ? checkoutSession.url : null;
Expand Down
Loading