diff --git a/apps/web/src/lib/autoTopUp.test.ts b/apps/web/src/lib/autoTopUp.test.ts index 31be4296cb..0c161f0e7d 100644 --- a/apps/web/src/lib/autoTopUp.test.ts +++ b/apps/web/src/lib/autoTopUp.test.ts @@ -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: { @@ -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' }); @@ -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' }); @@ -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', + }) + ); }); }); diff --git a/apps/web/src/lib/autoTopUp.ts b/apps/web/src/lib/autoTopUp.ts index 6e891610cc..6c27b28e05 100644 --- a/apps/web/src/lib/autoTopUp.ts +++ b/apps/web/src/lib/autoTopUp.ts @@ -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) @@ -261,30 +284,51 @@ async function performAutoTopUpForEntity( // Credit application is handled by the `invoice.paid` webhook. const invoiceMetadata: Record = 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.) @@ -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, { diff --git a/apps/web/src/lib/organizations/organization-auto-top-up.ts b/apps/web/src/lib/organizations/organization-auto-top-up.ts index e5cd90a6c3..406c3f63c6 100644 --- a/apps/web/src/lib/organizations/organization-auto-top-up.ts +++ b/apps/web/src/lib/organizations/organization-auto-top-up.ts @@ -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 { return ( @@ -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. @@ -25,46 +50,70 @@ export async function createOrgAutoTopUpSetupCheckoutSession( amountCents: number = DEFAULT_ORG_AUTO_TOP_UP_AMOUNT_CENTS ): Promise { 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; diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index c29205e987..01a01cd10f 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -83,6 +83,9 @@ import { successResult } from '@/lib/maybe-result'; import { observeStripeEarlyFraudWarningCreated } from '@/lib/stripe/early-fraud-warning'; import { observeStripeDisputeCreated } from '@/lib/stripe/disputes'; import { + createTopUpCheckoutSession, + mergeServiceFeeCommercialMetadata, + prepareTopUpCheckoutFee, resolveFixedUsdPriceUnitAmount, settleTrustedAutoTopUpInvoice, settleTrustedTopUpCharge, @@ -90,6 +93,14 @@ import { } from '@/lib/service-fees/checkout'; import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions'; +import { + observeServiceFeeChargeRefunded, + observeServiceFeeCreditNote, +} from '@/lib/service-fees/refunds'; +import { + observeServiceFeeDisputeClosed, + observeServiceFeeDisputeFundsWithdrawn, +} from '@/lib/service-fees/disputes'; type KiloClawChargeContext = { chargeId: string; @@ -1195,6 +1206,12 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { case 'charge.dispute.updated': case 'charge.dispute.closed': { const dispute = event.data.object; + if (event.type === 'charge.dispute.closed') { + await observeServiceFeeDisputeClosed({ + store: createServiceFeeStores().assessments, + dispute, + }); + } await syncStripeDisputeCaseFromWebhook({ eventId: event.id, eventCreated: event.created, @@ -1209,6 +1226,11 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } + await observeServiceFeeChargeRefunded({ + store: createServiceFeeStores().assessments, + charge, + stripe: client, + }); await suspendOrganizationKiloPassForAdverseCharge(charge.id); const referralAdverseCharge = await getAffiliateDisputeChargeContext(charge.id); if (!referralAdverseCharge) { @@ -1233,6 +1255,16 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } + case 'credit_note.created': + case 'credit_note.updated': { + await observeServiceFeeCreditNote({ + store: createServiceFeeStores().assessments, + creditNote: event.data.object, + stripe: client, + }); + break; + } + case 'charge.updated': { const charge = event.data.object; const isFraudMarked = @@ -1480,6 +1512,10 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { case 'charge.dispute.funds_withdrawn': { const dispute = event.data.object; + await observeServiceFeeDisputeFundsWithdrawn({ + store: createServiceFeeStores().assessments, + dispute, + }); void reportChargeBackedStripeAbuseEvent({ abuseEventType: 'stripe.charge.dispute.funds_withdrawn', eventId: event.id, @@ -1572,46 +1608,69 @@ export async function createAutoTopUpSetupCheckoutSession( amountCents: number = 5000 ): Promise { const amountDollars = amountCents / 100; + const feeDeps = createStripeTopUpFeeDeps(); + const prepared = await prepareTopUpCheckoutFee({ + flow: 'personal_auto_top_up_setup', + principalMinor: amountCents, + kiloUserId, + 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: '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 $${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: '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 $${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}/payments/auto-topup/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/profile?auto_topup_setup=cancelled`, - payment_intent_data: { - metadata: { - type: 'auto-topup-setup', - kiloUserId, - amountCents: String(amountCents), + customer_update: { + name: 'auto', + address: 'auto', }, - // KEY CONFIGURATION: This saves the payment method for future off-session charges - setup_future_usage: 'off_session', - }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/auto-topup/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/profile?auto_topup_setup=cancelled`, + metadata: mergeServiceFeeCommercialMetadata( + { type: 'auto-topup-setup', kiloUserId }, + prepared.commercialMetadata + ), + payment_intent_data: { + metadata: mergeServiceFeeCommercialMetadata( + { + type: 'auto-topup-setup', + kiloUserId, + amountCents: String(amountCents), + }, + prepared.commercialMetadata + ), + // KEY CONFIGURATION: This saves the payment method for future off-session charges + 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; @@ -1663,36 +1722,64 @@ export async function getStripeTopUpCheckoutUrl( cancelUrl = `${APP_URL}/profile?payment_status=topup_cancelled&origin=${origin}`; } - const checkoutSession = await client.checkout.sessions.create({ - mode: 'payment', - customer: stripeCustomerId, - billing_address_collection: 'required', - line_items: line_items, - invoice_creation: { - enabled: true, - }, - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/topup/success?session_id={CHECKOUT_SESSION_ID}&origin=${origin}`, - cancel_url: cancelUrl, - payment_intent_data: { - metadata: { - type: 'stripe-checkout-topup', - kiloUserId, - organizationId: organizationId ?? null, - amountCents: String(principalMinor), - serviceFeePrincipalMinor: String(principalMinor), - } satisfies StripeTopupMetadata, - }, - saved_payment_method_options: { - payment_method_save: 'enabled', - }, + const feeDeps = createStripeTopUpFeeDeps(); + const prepared = await prepareTopUpCheckoutFee({ + flow: isOrganizationTopUp ? 'organization_top_up' : 'personal_top_up', + principalMinor, + kiloUserId, + organizationId: organizationId ?? undefined, + stripeCustomerId: stripeCustomerId ?? undefined, + taxPrincipal: defaultPriceId ? { kind: 'price', priceId: defaultPriceId } : { kind: 'inline' }, + deps: feeDeps, + }); + const principalLine = line_items[0]; + + const checkoutSession = await createTopUpCheckoutSession({ + prepared, + buildSessionParams: feeLine => ({ + mode: 'payment', + customer: stripeCustomerId, + billing_address_collection: 'required', + line_items: feeLine ? [principalLine, feeLine] : [principalLine], + invoice_creation: { + enabled: true, + }, + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/topup/success?session_id={CHECKOUT_SESSION_ID}&origin=${origin}`, + cancel_url: cancelUrl, + metadata: mergeServiceFeeCommercialMetadata( + { + type: 'stripe-checkout-topup', + kiloUserId, + ...(organizationId ? { organizationId } : {}), + }, + prepared.commercialMetadata + ), + payment_intent_data: { + metadata: mergeServiceFeeCommercialMetadata( + { + type: 'stripe-checkout-topup', + kiloUserId, + organizationId: organizationId ?? null, + amountCents: String(principalMinor), + } satisfies StripeTopupMetadata, + prepared.commercialMetadata + ), + }, + saved_payment_method_options: { + payment_method_save: 'enabled', + }, + expand: ['line_items.data.price.product'], + }), + createSession: params => client.checkout.sessions.create(params), + deps: feeDeps, }); const url: string | null = typeof checkoutSession.url === 'string' ? checkoutSession.url : null; diff --git a/apps/web/src/routers/organizations/organization-auto-top-up-router.test.ts b/apps/web/src/routers/organizations/organization-auto-top-up-router.test.ts index c089716b34..8a5632fc01 100644 --- a/apps/web/src/routers/organizations/organization-auto-top-up-router.test.ts +++ b/apps/web/src/routers/organizations/organization-auto-top-up-router.test.ts @@ -1,6 +1,11 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { auto_top_up_configs, organizations } from '@kilocode/db/schema'; +import { + auto_top_up_configs, + organization_service_fee_exemptions, + organizations, + stripe_service_fee_assessments, +} from '@kilocode/db/schema'; import type { User, Organization } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; @@ -13,8 +18,12 @@ jest.mock('@/lib/stripe-client', () => ({ checkout: { sessions: { create: jest.fn().mockResolvedValue({ + id: 'cs_test_org_auto_topup', + created: 1_700_000_000, url: 'https://checkout.stripe.com/test-session', }), + listLineItems: jest.fn().mockResolvedValue({ data: [], has_more: false }), + expire: jest.fn().mockResolvedValue({}), }, }, }, @@ -62,6 +71,12 @@ describe('organization auto-top-up router', () => { await db .delete(auto_top_up_configs) .where(eq(auto_top_up_configs.owned_by_organization_id, testOrg.id)); + await db + .delete(stripe_service_fee_assessments) + .where(eq(stripe_service_fee_assessments.organization_id, testOrg.id)); + await db + .delete(organization_service_fee_exemptions) + .where(eq(organization_service_fee_exemptions.organization_id, testOrg.id)); await db.delete(organizations).where(eq(organizations.id, testOrg.id)); });