diff --git a/.specs/impact-affiliate-tracking.md b/.specs/impact-affiliate-tracking.md index 63541952db..38b9ca2df5 100644 --- a/.specs/impact-affiliate-tracking.md +++ b/.specs/impact-affiliate-tracking.md @@ -51,7 +51,8 @@ BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capital governed by KiloClaw billing. Zero-dollar periods, fully comped periods, organization-scoped KiloClaw activity, and admin-only interventions are excluded. - **Affiliate-eligible Kilo Pass invoice settlement**: Kilo Pass Stripe invoice settlement for an attributed user with - positive paid amount and resolvable Kilo Pass tier and cadence. Initial purchases and renewals can qualify. + a positive settled eligible product amount and resolvable Kilo Pass tier and cadence. The eligible product amount + excludes any service-fee line. Initial purchases and renewals can qualify. - **Reported amount**: Monetary amount represented in the payment currency's major units from the authoritative monetized amount for the eligible event. Catalog/list price and Kilo Pass credit issuance amounts are not substitutes. - **Kilo Pass tier**: Eligible package level `19`, `49`, or `199`. @@ -148,8 +149,10 @@ after the winning attribution is established. payment period or invoice they represent. 17. SALE events MUST report the eligible event's reported amount and payment currency. KiloClaw SALE amounts MUST use - the monetized KiloClaw payment-period amount. Kilo Pass SALE amounts MUST use the positive settled invoice paid - amount, not catalog price or credit issuance value. + the monetized KiloClaw payment-period amount. Kilo Pass SALE amounts MUST use the settled eligible product amount, + excluding any service-fee line. That product amount is the service-fee assessment's `settled_product_minor` + converted to major units, not the gross settled invoice paid amount, catalog price, or credit issuance value. A + zero settled product amount MUST NOT produce a SALE. 18. The reported amount MUST be normalized to the payment currency's major units without changing the authoritative settled or monetized value. Any rounding needed by a provider integration must preserve that business amount. @@ -252,6 +255,11 @@ after the winning attribution is established. ## Changelog +### 2026-08-11 -- Kilo Pass SALE amount excludes the service fee + +Amended rule 17 so Kilo Pass SALE amounts use the assessment's settled eligible product amount +(`settled_product_minor`), not the gross settled invoice paid amount. A zero product amount suppresses SALE. + ### 2026-05-28 -- Enforced EFW refund reversals Expanded adverse SALE reversal to enforced Stripe Early Fraud Warning refunds so proactive refunds can reverse a full eligible affiliate commission without waiting for a dispute, while preserving reversal identity and deduplication requirements. diff --git a/apps/web/src/emails/AGENTS.md b/apps/web/src/emails/AGENTS.md index a618ca490f..66bcdad129 100644 --- a/apps/web/src/emails/AGENTS.md +++ b/apps/web/src/emails/AGENTS.md @@ -97,7 +97,7 @@ Every template must include this branding footer below the content table: | `accountDeletionRequest.html` | `email`, `year` | — | | `userDataExportReady.html` | `data_exports_url`, `expiry_date`, `year` | — | | `dataExportDownloadCode.html` | `code`, `email`, `expires_in`, `year` | — | -| `creditsTopUp.html` | `heading`, `intro`, `amount_usd`, `credits_usd`, `purchase_date`, `credits_url`, `receipt_section`, `year`. Org variants render org-specific copy into `intro` before template rendering; when provided, the organization name is interpolated there rather than passed as a separate template variable. | — | +| `creditsTopUp.html` | `heading`, `intro`, `amount_label`, `amount_usd`, `service_fee_section`, `credits_row_section`, `purchase_date`, `credits_url`, `receipt_section`, `year`. `amount_label` is `Amount` when there is no service fee and `Credits added` when `serviceFeeCents > 0`. `service_fee_section` and `credits_row_section` are escaped RawHtml built in `email.ts`: fee-free top-ups keep the current Amount/Credits/Date summary, and a charged fee replaces that with Credits added / Service fee (5%) / Total paid / Date. Do not put exemption or fee-failure reasons in this email. Org variants render org-specific copy into `intro` before template rendering; when provided, the organization name is interpolated there rather than passed as a separate template variable. | — | | `kiloClawSubscriptionStarted.html` | `plan_name`, `price_usd`, `billing_period`, `next_billing_date`, `manage_url`, `year` | — | | `securityFindingNew.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `action_url`, `manage_notifications_url`, `year` | — | | `securityFindingSlaWarning.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `sla_deadline`, `action_url`, `manage_notifications_url`, `year` | — | diff --git a/apps/web/src/emails/creditsTopUp.html b/apps/web/src/emails/creditsTopUp.html index 0af90f31ac..505a0bb8e8 100644 --- a/apps/web/src/emails/creditsTopUp.html +++ b/apps/web/src/emails/creditsTopUp.html @@ -77,21 +77,10 @@ sans-serif; " > - Amount: ${{ amount_usd }} USD -

-

- Credits: ${{ credits_usd }} USD + {{ amount_label }}: + ${{ amount_usd }} USD

+ {{ service_fee_section }} {{ credits_row_section }}

${escapeHtml(label)}: ${escapeHtml(value)}

`; +} + +// Optional fee + total-paid rows. Fee-free callers get empty HTML so the +// template can keep the Amount/Credits summary with no exemption copy. +export function buildCreditsTopUpServiceFeeSection(params: { + serviceFeeCents: number; + grossPaidCents: number; +}): RawHtml { + if (params.serviceFeeCents <= 0) { + return new RawHtml(''); + } + + return new RawHtml( + buildCreditsTopUpInfoRow('Service fee (5%)', `$${formatUsd(params.serviceFeeCents)} USD`) + + buildCreditsTopUpInfoRow('Total paid', `$${formatUsd(params.grossPaidCents)} USD`) + ); +} + +// Fee itemization already labels the granted amount as Credits added, so this +// row is omitted when a fee is charged. Fee-free callers keep Credits. +export function buildCreditsTopUpCreditsRowSection(params: { + creditsCents: number; + hasServiceFee: boolean; +}): RawHtml { + if (params.hasServiceFee) { + return new RawHtml(''); + } + + return new RawHtml(buildCreditsTopUpInfoRow('Credits', `$${formatUsd(params.creditsCents)} USD`)); +} + function formatDate(date: Date): string { // Dates surfaced to end-users; the server locale is stable (UTC in prod) so // explicit en-US formatting avoids surprise month-name changes in tests. @@ -592,6 +630,7 @@ export async function sendCreditsTopUpEmail( const credits_url = isOrgVariant ? props.creditsUrl || `${NEXTAUTH_URL}/organizations/${props.organizationId}/payment-details` : `${NEXTAUTH_URL}/credits`; + const hasServiceFee = props.serviceFeeCents > 0; return send({ to: props.to, templateName: 'creditsTopUp', @@ -599,8 +638,16 @@ export async function sendCreditsTopUpEmail( templateVars: { heading: copy.heading, intro: copy.intro(organizationName), - amount_usd: formatUsd(props.amountCents), - credits_usd: formatUsd(props.creditsCents), + amount_label: hasServiceFee ? 'Credits added' : 'Amount', + amount_usd: formatUsd(props.principalCents), + service_fee_section: buildCreditsTopUpServiceFeeSection({ + serviceFeeCents: props.serviceFeeCents, + grossPaidCents: props.grossPaidCents, + }), + credits_row_section: buildCreditsTopUpCreditsRowSection({ + creditsCents: props.creditsCents, + hasServiceFee, + }), purchase_date: formatDate(props.purchaseDate), credits_url, receipt_section: buildCreditsTopUpReceiptSection(props.receiptUrl), diff --git a/apps/web/src/lib/kilo-pass/affiliate-sale.test.ts b/apps/web/src/lib/kilo-pass/affiliate-sale.test.ts new file mode 100644 index 0000000000..92319c9d36 --- /dev/null +++ b/apps/web/src/lib/kilo-pass/affiliate-sale.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import type Stripe from 'stripe'; + +import { KiloPassCadence, KiloPassTier } from '@/lib/kilo-pass/enums'; +import type * as affiliateEventsModule from '@/lib/impact/affiliate-events'; + +// Use global `jest.mock` so SWC hoists it before the static import below. +jest.mock('@/lib/impact/affiliate-events', () => { + const actual = jest.requireActual('@/lib/impact/affiliate-events'); + return { + __esModule: true, + ...actual, + enqueueAffiliateEventForUser: jest.fn(async () => null), + }; +}); + +import { enqueueKiloPassAffiliateSaleForInvoice } from '@/lib/kilo-pass/affiliate-sale'; + +const enqueueAffiliateEventForUser = jest.mocked( + jest.requireMock('@/lib/impact/affiliate-events') + .enqueueAffiliateEventForUser +); + +function makeInvoice(params: { + id: string; + amountPaid: number; + currency?: string; + paidAt?: number; + promotionCode?: string; +}): Stripe.Invoice { + return { + id: params.id, + object: 'invoice', + amount_paid: params.amountPaid, + currency: params.currency ?? 'usd', + status_transitions: { + paid_at: params.paidAt ?? 1_767_830_400, + }, + discounts: params.promotionCode + ? [ + { + id: 'di_test', + object: 'discount', + promotion_code: { + id: 'promo_test', + object: 'promotion_code', + code: params.promotionCode, + }, + }, + ] + : [], + payments: { + object: 'list', + has_more: false, + url: `/v1/invoices/${params.id}/payments`, + data: [ + { + id: 'inpay_test', + object: 'invoice_payment', + status: 'paid', + payment: { + type: 'charge', + charge: 'ch_affiliate_test', + }, + }, + ], + }, + } as unknown as Stripe.Invoice; +} + +describe('enqueueKiloPassAffiliateSaleForInvoice', () => { + beforeEach(() => { + enqueueAffiliateEventForUser.mockReset(); + enqueueAffiliateEventForUser.mockResolvedValue(null); + }); + + test('reports explicit productAmountMinor and never the invoice gross', async () => { + const invoice = makeInvoice({ + id: 'in_affiliate_product', + amountPaid: 5_145, + currency: 'eur', + promotionCode: 'SAVE20', + }); + + await enqueueKiloPassAffiliateSaleForInvoice({ + eventId: 'evt_affiliate_product', + invoice, + stripe: {} as Stripe, + context: { + userId: 'user_1', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + itemSku: 'price_kilo_pass_tier_49_monthly', + }, + productAmountMinor: 3_920, + }); + + expect(enqueueAffiliateEventForUser).toHaveBeenCalledTimes(1); + expect(enqueueAffiliateEventForUser).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_1', + provider: 'impact', + eventType: 'sale', + orderId: 'in_affiliate_product', + amount: 39.2, + currencyCode: 'eur', + itemCategory: 'kilo-pass-tier-49-monthly', + itemName: 'Kilo Pass Tier 49 Monthly', + itemSku: 'price_kilo_pass_tier_49_monthly', + promoCode: 'SAVE20', + stripeChargeId: 'ch_affiliate_test', + }) + ); + const reportedAmount = enqueueAffiliateEventForUser.mock.calls[0]?.[0]?.amount; + expect(reportedAmount).not.toBe(invoice.amount_paid / 100); + }); + + test('zero productAmountMinor suppresses sale even when invoice.amount_paid is positive', async () => { + await enqueueKiloPassAffiliateSaleForInvoice({ + eventId: 'evt_affiliate_zero_product', + invoice: makeInvoice({ + id: 'in_affiliate_zero_product', + amountPaid: 245, + }), + stripe: {} as Stripe, + context: { + userId: 'user_1', + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }, + productAmountMinor: 0, + }); + + expect(enqueueAffiliateEventForUser).not.toHaveBeenCalled(); + }); + + test('missing context suppresses sale without reading gross', async () => { + await enqueueKiloPassAffiliateSaleForInvoice({ + eventId: 'evt_affiliate_missing_context', + invoice: makeInvoice({ + id: 'in_affiliate_missing_context', + amountPaid: 4_900, + }), + stripe: {} as Stripe, + context: null, + productAmountMinor: 4_900, + }); + + expect(enqueueAffiliateEventForUser).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/affiliate-sale.ts b/apps/web/src/lib/kilo-pass/affiliate-sale.ts index aaf1ff8513..5d520995e5 100644 --- a/apps/web/src/lib/kilo-pass/affiliate-sale.ts +++ b/apps/web/src/lib/kilo-pass/affiliate-sale.ts @@ -139,9 +139,10 @@ export async function enqueueKiloPassAffiliateSaleForInvoice(params: { invoice: Stripe.Invoice; stripe: Stripe; context: KiloPassAffiliateSaleContext | null; + productAmountMinor: number; }): Promise { - const { eventId, invoice, stripe, context } = params; - if (!context || invoice.amount_paid <= 0) { + const { eventId, invoice, stripe, context, productAmountMinor } = params; + if (!context || productAmountMinor <= 0) { return; } @@ -164,7 +165,7 @@ export async function enqueueKiloPassAffiliateSaleForInvoice(params: { }), eventDate, orderId: invoice.id, - amount: invoice.amount_paid / 100, + amount: productAmountMinor / 100, currencyCode: invoice.currency ?? 'usd', ...getKiloPassAffiliateSaleReportingFields(context), ...(promoCode ? { promoCode } : {}), diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts index 2e4543cc8e..593ba348ea 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts @@ -32,6 +32,15 @@ import type Stripe from 'stripe'; import type * as affiliateEventsModule from '@/lib/impact/affiliate-events'; import { randomUUID } from 'node:crypto'; import { digestCardFingerprint } from '@/lib/kilo-pass/card-fingerprint-gate'; +import { + markServiceFeeAssessmentCharged, + prepareServiceFeeAssessmentDecision, + upsertServiceFeeAssessment, +} from '@/lib/service-fees/assessments'; +import { createInvoiceServiceFeeAssessmentKey } from '@/lib/service-fees/checkout'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; +import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; +import { buildServiceFeeLineMetadata } from '@/lib/service-fees/stripe-lines'; jest.mock('@/lib/kilo-pass/posthog-tracking', () => ({ runAfterResponse: async (work: () => Promise) => { @@ -325,6 +334,131 @@ async function seedBaseIssuance(params: { }); } +async function persistPersonalKiloPassAssessment(params: { + invoiceId: string; + kiloUserId: string; + eligibleSubtotalMinor: number; + chargedFeeMinor: number; +}): Promise { + const store = createServiceFeeStores().assessments; + const assessmentKey = createInvoiceServiceFeeAssessmentKey(params.invoiceId); + const decision = await prepareServiceFeeAssessmentDecision({ + assessmentKey, + flow: 'personal_kilo_pass', + currency: 'usd', + eligibilityCreatedAt: new Date(SERVICE_FEE_ACTIVATION_UNIX_SECONDS * 1000), + eligibleSubtotalMinor: params.eligibleSubtotalMinor, + kiloUserId: params.kiloUserId, + stripeCustomerId: 'cus_kilo_pass_test', + }); + const record = await upsertServiceFeeAssessment({ + store, + decision, + stripeIds: { + stripeCustomerId: 'cus_kilo_pass_test', + stripeInvoiceId: params.invoiceId, + stripeInvoiceFeeLineItemId: `il_fee_${params.invoiceId}`, + }, + }); + if (decision.outcome === 'pending') { + await markServiceFeeAssessmentCharged({ + store, + assessmentKey: record.assessmentKey, + chargedFeeMinor: params.chargedFeeMinor, + stripeIds: { + stripeInvoiceFeeLineItemId: `il_fee_${params.invoiceId}`, + }, + }); + } + return assessmentKey; +} + +function attachSettledKiloPassLines( + invoice: Stripe.Invoice, + params: { + priceId: string; + assessmentKey: string; + productMinor: number; + feeMinor: number; + listProductMinor?: number; + listFeeMinor?: number; + } +): Stripe.Invoice { + const listProductMinor = params.listProductMinor ?? params.productMinor; + const listFeeMinor = params.listFeeMinor ?? params.feeMinor; + invoice.status = 'paid'; + invoice.lines = { + object: 'list', + has_more: false, + url: `/v1/invoices/${invoice.id}/lines`, + data: [ + { + id: `il_pass_${invoice.id}`, + object: 'line_item', + amount: listProductMinor, + currency: invoice.currency ?? 'usd', + description: 'Kilo Pass', + discountable: true, + discount_amounts: null, + discounts: [], + invoice: invoice.id, + livemode: false, + metadata: {}, + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: + listProductMinor === params.productMinor + ? null + : [ + { + amount: listProductMinor - params.productMinor, + type: 'discount', + discount: 'di_test', + }, + ], + pricing: { + type: 'price_details', + unit_amount_decimal: String(listProductMinor), + price_details: { price: params.priceId, product: 'prod_kilo_pass' }, + }, + quantity: 1, + subscription: null, + taxes: null, + } as Stripe.InvoiceLineItem, + { + id: `il_fee_${invoice.id}`, + object: 'line_item', + amount: listFeeMinor, + currency: invoice.currency ?? 'usd', + description: 'Service fee (5%)', + discountable: false, + discount_amounts: null, + discounts: [], + invoice: invoice.id, + livemode: false, + metadata: buildServiceFeeLineMetadata(params.assessmentKey), + parent: null, + period: { start: 1, end: 2 }, + pretax_credit_amounts: + listFeeMinor === params.feeMinor + ? null + : [ + { + amount: listFeeMinor - params.feeMinor, + type: 'discount', + discount: 'di_test', + }, + ], + pricing: null, + quantity: 1, + subscription: null, + taxes: null, + } as Stripe.InvoiceLineItem, + ], + }; + return invoice; +} + function makeInvoicesListMock(params: { yearlyPeriodStartSeconds: number; yearlyPeriodEndSeconds: number; @@ -1251,6 +1385,238 @@ describe('handleKiloPassInvoicePaid', () => { ); }); + test('monthly: discounted settlement reports product amount and excludes the service fee', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + await seedDeliveredImpactSignupEvent(user.id, user.google_user_email); + const stripeSubId = `sub_affiliate_discount_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const invoiceId = `inv_affiliate_discount_${Math.random()}`; + const assessmentKey = await persistPersonalKiloPassAssessment({ + invoiceId, + kiloUserId: user.id, + eligibleSubtotalMinor: 4_900, + chargedFeeMinor: 245, + }); + const invoice = attachSettledKiloPassLines( + makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 4_116, + currency: 'usd', + period_start_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + created_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + paid_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 10, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_cycle', + }), + { + priceId, + assessmentKey, + productMinor: 3_920, + feeMinor: 196, + listProductMinor: 4_900, + listFeeMinor: 245, + } + ); + + await handleKiloPassInvoicePaid({ + eventId: 'evt_affiliate_discount', + invoice, + stripe: { + subscriptions: { + retrieve: jest.fn(async () => subscription), + }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + amountPaidUsd: 39.2, + stripeInvoiceId: invoiceId, + }) + ); + const events = await db + .select() + .from(user_affiliate_events) + .where(eq(user_affiliate_events.user_id, user.id)); + const saleEvent = events.find(event => event.event_type === 'sale'); + expect(saleEvent).toEqual( + expect.objectContaining({ + payload_json: expect.objectContaining({ + orderId: invoiceId, + amount: 39.2, + currencyCode: 'usd', + itemCategory: 'kilo-pass-tier-49-monthly', + }), + }) + ); + expect(saleEvent?.payload_json.amount).not.toBe(41.16); + }); + + test('product analytics fallback never uses a fee-inclusive gross amount', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + await seedDeliveredImpactSignupEvent(user.id, user.google_user_email); + const stripeSubId = `sub_affiliate_gross_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const invoiceId = `inv_affiliate_gross_${Math.random()}`; + const invoice = attachSettledKiloPassLines( + makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 5_145, + currency: 'usd', + period_start_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + created_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + paid_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 10, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_cycle', + }), + { + priceId, + assessmentKey: createInvoiceServiceFeeAssessmentKey(invoiceId), + productMinor: 4_900, + feeMinor: 245, + } + ); + + await handleKiloPassInvoicePaid({ + eventId: 'evt_affiliate_gross', + invoice, + stripe: { + subscriptions: { + retrieve: jest.fn(async () => subscription), + }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + amountPaidUsd: 49, + stripeInvoiceId: invoiceId, + }) + ); + const events = await db + .select() + .from(user_affiliate_events) + .where(eq(user_affiliate_events.user_id, user.id)); + const saleEvent = events.find(event => event.event_type === 'sale'); + expect(saleEvent?.payload_json.amount).toBe(49); + expect(saleEvent?.payload_json.amount).not.toBe(51.45); + }); + + test('monthly: 100% discount settles zero product and suppresses affiliate sale', async () => { + const trackingMock = getPosthogTrackingMock(); + const { handleKiloPassInvoicePaid } = + await import('@/lib/kilo-pass/stripe-handlers-invoice-paid'); + const user = await insertTestUser({ total_microdollars_acquired: 0, microdollars_used: 0 }); + await seedDeliveredImpactSignupEvent(user.id, user.google_user_email); + const stripeSubId = `sub_affiliate_free_${Math.random()}`; + const meta = kiloPassMetadata({ + kiloUserId: user.id, + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const subscription = makeStripeSubscription({ + id: stripeSubId, + start_date_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: meta, + }); + const priceId = await getKiloPassPriceId({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }); + const invoiceId = `inv_affiliate_free_${Math.random()}`; + const assessmentKey = await persistPersonalKiloPassAssessment({ + invoiceId, + kiloUserId: user.id, + eligibleSubtotalMinor: 4_900, + chargedFeeMinor: 0, + }); + const invoice = attachSettledKiloPassLines( + makeStripeInvoice({ + id: invoiceId, + amount_paid_cents: 0, + currency: 'usd', + period_start_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + created_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + paid_seconds: SERVICE_FEE_ACTIVATION_UNIX_SECONDS + 10, + priceId, + subscriptionIdOrExpanded: stripeSubId, + metadata: meta, + billingReason: 'subscription_cycle', + }), + { + priceId, + assessmentKey, + productMinor: 0, + feeMinor: 0, + listProductMinor: 4_900, + listFeeMinor: 245, + } + ); + + await handleKiloPassInvoicePaid({ + eventId: 'evt_affiliate_free', + invoice, + stripe: { + subscriptions: { + retrieve: jest.fn(async () => subscription), + }, + } as unknown as Stripe, + }); + + expect(trackingMock.trackKiloPassPurchaseCompleted).toHaveBeenCalledWith( + expect.objectContaining({ + amountPaidUsd: 0, + stripeInvoiceId: invoiceId, + }) + ); + const saleEvents = await db + .select() + .from(user_affiliate_events) + .where( + and( + eq(user_affiliate_events.user_id, user.id), + eq(user_affiliate_events.event_type, 'sale') + ) + ); + expect(saleEvents).toHaveLength(0); + }); + test('monthly: referral conversion processor suppresses affiliate SALE when referral wins', async () => { const processPersonalKiloPassStripePaidConversion = jest.fn(async (_params: unknown) => ({ shouldEnqueueAffiliateSale: false, diff --git a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts index 294aef75a2..836f499a10 100644 --- a/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts +++ b/apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts @@ -72,6 +72,12 @@ import { type KiloPassAffiliateSaleContext, } from '@/lib/kilo-pass/affiliate-sale'; import { processPersonalKiloPassStripePaidConversion } from '@/lib/impact/kilo-pass-referrals'; +import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; +import { + settleKiloPassInvoiceServiceFee, + type KiloPassServiceFeeSettlementResult, +} from '@/lib/service-fees/settlement'; +import { isServiceFeeInvoiceLine } from '@/lib/service-fees/stripe-lines'; import { runAfterResponse, trackKiloPassPurchaseCompleted, @@ -422,6 +428,28 @@ async function hasHandledKiloPassInvoicePaid( return existing !== undefined; } +function invoiceHasServiceFeeLine(invoice: Stripe.Invoice): boolean { + return (invoice.lines?.data ?? []).some(line => isServiceFeeInvoiceLine(line)); +} + +function productOnlyKiloPassAnalyticsAmount(invoice: Stripe.Invoice): number { + if (invoiceHasServiceFeeLine(invoice)) return 0; + return invoice.amount_paid; +} + +function productAmountFromServiceFeeSettlement( + invoice: Stripe.Invoice, + settlement: KiloPassServiceFeeSettlementResult +): number { + if (settlement.status === 'settled' || settlement.assessment) { + return settlement.settledProductMinor; + } + if (invoiceHasServiceFeeLine(invoice)) { + return settlement.settledProductMinor; + } + return invoice.amount_paid; +} + function purchaseKindFromBillingReason( billingReason: Stripe.Invoice['billing_reason'] ): KiloPassPurchaseKind { @@ -452,6 +480,7 @@ export async function handleKiloPassInvoicePaid(params: { // invoice handling when Stripe did not surface the matching price line. if (!invoiceLooksLikeKiloPassByPriceId(invoice) && !metadataFromInvoice) return; + let settledProductMinor = productOnlyKiloPassAnalyticsAmount(invoice); let didMutateBalance = false; let kiloUserIdForCache: string | null = null; const affiliateSaleState: { @@ -502,6 +531,25 @@ export async function handleKiloPassInvoicePaid(params: { } const priceMetadata = getKiloPassPriceMetadataFromInvoice(invoice); + try { + const serviceFeeSettlement = await settleKiloPassInvoiceServiceFee({ + invoice, + stripe, + store: createServiceFeeStores(tx).assessments, + subscription, + }); + settledProductMinor = productAmountFromServiceFeeSettlement(invoice, serviceFeeSettlement); + } catch (error) { + captureException(error, { + tags: { source: 'kilo_pass_service_fee_settlement' }, + extra: { + stripeEventId: eventId, + stripeInvoiceId: invoice.id, + stripeSubscriptionId: subscription.id, + }, + }); + settledProductMinor = productOnlyKiloPassAnalyticsAmount(invoice); + } const kiloUserId = metadata.kiloUserId; const tier = priceMetadata?.tier ?? metadata.tier; @@ -919,7 +967,7 @@ export async function handleKiloPassInvoicePaid(params: { cadence: referralConversionState.cadence, purchaseKind: purchaseKindFromBillingReason(invoice.billing_reason), stripeInvoiceId: invoice.id, - amountPaidUsd: invoice.amount_paid / 100, + amountPaidUsd: settledProductMinor / 100, currency: (invoice.currency ?? 'usd').toLowerCase(), livemode: invoice.livemode, }); @@ -956,7 +1004,7 @@ export async function handleKiloPassInvoicePaid(params: { kiloPassSubscriptionId: referralConversionState.kiloPassSubscriptionId, sourcePaymentId: invoice.id, orderId: invoice.id, - amount: invoice.amount_paid / 100, + amount: settledProductMinor / 100, currencyCode: invoice.currency ?? 'usd', itemCategory: reportingFields.itemCategory, itemName: reportingFields.itemName, @@ -977,6 +1025,7 @@ export async function handleKiloPassInvoicePaid(params: { invoice, stripe, context: affiliateSaleState.context, + productAmountMinor: settledProductMinor, }); } diff --git a/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.test.ts b/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.test.ts index 383a5fe4cc..66a06b8d8d 100644 --- a/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.test.ts +++ b/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.test.ts @@ -1,5 +1,40 @@ import { describe, expect, test } from '@jest/globals'; -import { invoiceLooksLikeOrganizationKiloPass } from './stripe-invoice-classifier.server'; +import type Stripe from 'stripe'; + +import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; +import { + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { buildServiceFeeLineMetadata } from '@/lib/service-fees/stripe-lines'; +import { + invoiceLooksLikeKiloPassByPriceId, + invoiceLooksLikeOrganizationKiloPass, +} from './stripe-invoice-classifier.server'; + +const KILO_PASS_PRICE_ID = getKnownStripePriceIdsForKiloPass()[0]!; + +function invoiceWithLines( + lines: Array<{ + priceId?: string; + metadata?: Stripe.Metadata; + }> +): Stripe.Invoice { + return { + lines: { + data: lines.map((line, index) => ({ + id: `il_${index}`, + metadata: line.metadata ?? {}, + pricing: line.priceId + ? { + price_details: { price: line.priceId }, + } + : null, + })), + }, + } as unknown as Stripe.Invoice; +} describe('organization Kilo Pass invoice classifier', () => { test('classifies explicit organization metadata before shared personal price fallback', () => { @@ -19,3 +54,48 @@ describe('organization Kilo Pass invoice classifier', () => { expect(invoiceLooksLikeOrganizationKiloPass(invoice as never)).toBe(true); }); }); + +describe('personal Kilo Pass invoice classifier', () => { + test('recognizes a known Kilo Pass price before any later fee line', () => { + expect( + invoiceLooksLikeKiloPassByPriceId( + invoiceWithLines([ + { priceId: KILO_PASS_PRICE_ID }, + { + priceId: 'price_service_fee', + metadata: buildServiceFeeLineMetadata('checkout:kilo-pass-fee'), + }, + ]) + ) + ).toBe(true); + }); + + test('ignores service-fee lines before known Kilo Pass price evidence', () => { + const feeOnlyReusingKnownPrice = invoiceWithLines([ + { + priceId: KILO_PASS_PRICE_ID, + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: 'checkout:kilo-pass-fee', + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + }, + ]); + + expect(invoiceLooksLikeKiloPassByPriceId(feeOnlyReusingKnownPrice)).toBe(false); + expect( + invoiceLooksLikeKiloPassByPriceId( + invoiceWithLines([ + { + priceId: 'price_service_fee', + metadata: buildServiceFeeLineMetadata('checkout:kilo-pass-fee'), + }, + ]) + ) + ).toBe(false); + expect(invoiceLooksLikeKiloPassByPriceId(invoiceWithLines([{ priceId: 'price_other' }]))).toBe( + false + ); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.ts b/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.ts index ec817afcd6..393250e51b 100644 --- a/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.ts +++ b/apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.ts @@ -4,12 +4,14 @@ import type Stripe from 'stripe'; import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server'; import { getOrganizationKiloPassMetadata } from '@/lib/kilo-pass-org/stripe-metadata'; +import { isServiceFeeInvoiceLine } from '@/lib/service-fees/stripe-lines'; function getInvoiceLinePriceIds(invoice: Stripe.Invoice): string[] { const ids: string[] = []; const lines = invoice.lines?.data ?? []; for (const line of lines) { + if (isServiceFeeInvoiceLine(line)) continue; const priceId = line.pricing?.price_details?.price ?? null; if (priceId) ids.push(priceId); } diff --git a/apps/web/src/lib/kiloclaw/stripe-invoice-classifier.server.test.ts b/apps/web/src/lib/kiloclaw/stripe-invoice-classifier.server.test.ts index b1c6a1ab8c..af20cc7c5d 100644 --- a/apps/web/src/lib/kiloclaw/stripe-invoice-classifier.server.test.ts +++ b/apps/web/src/lib/kiloclaw/stripe-invoice-classifier.server.test.ts @@ -5,6 +5,12 @@ import { classifyKiloClawInvoiceLine, invoiceLooksLikeKiloClawByPriceId, } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; +import { + SERVICE_FEE_METADATA_TYPE, + SERVICE_FEE_RATE_BASIS_POINTS, + SERVICE_FEE_VERSION, +} from '@/lib/service-fees/constants'; +import { buildServiceFeeLineMetadata } from '@/lib/service-fees/stripe-lines'; function requiredEnv(key: string): string { const value = process.env[key]; @@ -89,4 +95,51 @@ describe('KiloClaw Stripe invoice classification', () => { ).toBe(true); expect(invoiceLooksLikeKiloClawByPriceId(invoiceWithPrice('price_unrelated'))).toBe(false); }); + + it('does not treat a service-fee line as KiloClaw', () => { + const feeInvoice = { + id: 'in_service_fee', + object: 'invoice', + lines: { + data: [ + { + metadata: { + type: SERVICE_FEE_METADATA_TYPE, + serviceFeeVersion: SERVICE_FEE_VERSION, + serviceFeeAssessmentKey: 'checkout:fee', + serviceFeeRateBasisPoints: String(SERVICE_FEE_RATE_BASIS_POINTS), + }, + pricing: { + price_details: { price: 'price_service_fee' }, + }, + }, + ], + }, + } as unknown as Stripe.Invoice; + + expect(invoiceLooksLikeKiloClawByPriceId(feeInvoice)).toBe(false); + expect(classifyKiloClawInvoiceLine(feeInvoice)).toBeNull(); + expect( + invoiceLooksLikeKiloClawByPriceId({ + id: 'in_claw_plus_fee', + object: 'invoice', + lines: { + data: [ + { + pricing: { + price_details: { + price: requiredEnv('STRIPE_KILOCLAW_2026_05_10_STANDARD_PRICE_ID'), + }, + }, + period: { start: 1772323200, end: 1775001600 }, + }, + { + metadata: buildServiceFeeLineMetadata('checkout:fee'), + pricing: { price_details: { price: 'price_service_fee' } }, + }, + ], + }, + } as unknown as Stripe.Invoice) + ).toBe(true); + }); }); diff --git a/apps/web/src/lib/organizations/organization-billing.test.ts b/apps/web/src/lib/organizations/organization-billing.test.ts index 64daf92bd9..e05c1760bf 100644 --- a/apps/web/src/lib/organizations/organization-billing.test.ts +++ b/apps/web/src/lib/organizations/organization-billing.test.ts @@ -592,8 +592,9 @@ describe('processTopupForOrganization', () => { expect(topUpEmail.to).toBe(testUser.google_user_email); expect(topUpEmail.subject).toBe('Your Kilo org credit top-up'); expect(topUpEmail.html).toContain('Team credits added'); - expect(topUpEmail.html).toContain('Amount: $50.00 USD'); + expect(topUpEmail.html).toContain('Amount:'); expect(topUpEmail.html).toContain('Credits: $50.00 USD'); + expect(topUpEmail.html).toContain('$50.00 USD'); expect(topUpEmail.html).toContain( 'A Kilo credit top-up has been processed for Test Organization. The credits are now available to the organization.' ); @@ -633,18 +634,51 @@ describe('processTopupForOrganization', () => { expect(topUpEmail.subject).toBe('Kilo team auto top-up successful'); expect(topUpEmail.html).toContain('Team auto top-up was successful'); expect(topUpEmail.html).toContain('$25.00 USD'); + expect(topUpEmail.html).toContain('Amount:'); + expect(topUpEmail.html).not.toContain('Service fee (5%)'); expect(topUpEmail.html).toContain( 'Test Organization was automatically topped up so your team can keep using Kilo without interruption. The new credits are available now.' ); expect(topUpEmail.html).toContain(`/organizations/${testOrganization.id}/payment-details`); expect(topUpEmail.html).toContain('https://pay.stripe.test/receipts/ch'); + }); + + test('itemizes credits added, service fee, and gross when a positive fee is passed', async () => { + const amountInCents = 5000; + const stripePaymentId = 'pi_test_org_service_fee'; + mockResolveStripeReceiptUrl.mockResolvedValueOnce('https://pay.stripe.test/receipts/fee'); + + await processTopupForOrganization( + testUser.id, + testOrganization.id, + amountInCents, + { + type: 'stripe', + stripe_payment_id: stripePaymentId, + }, + { + serviceFeeCents: 250, + grossPaidCents: 5250, + creditsCents: 5000, + } + ); + + expect(sendViaMailgunMock).toHaveBeenCalledTimes(1); + const [topUpEmail] = sendViaMailgunMock.mock.calls[0]; + expect(topUpEmail.html).toContain('Credits added:'); + expect(topUpEmail.html).toContain('$50.00 USD'); + expect(topUpEmail.html).toContain('Service fee (5%): $2.50 USD'); + expect(topUpEmail.html).toContain('Total paid: $52.50 USD'); + expect(topUpEmail.html).not.toContain('Credits: $50.00 USD'); + expect(topUpEmail.html).not.toContain('Credit principal'); + expect(topUpEmail.html).not.toContain('Amount: $50.00 USD'); const emailMarkers = await getOrganizationTopUpEmailMarkers(stripePaymentId); expect(emailMarkers).toHaveLength(1); expect(emailMarkers[0]).toMatchObject({ email_type: 'organization_credits_top_up_confirmation', idempotency_key: stripePaymentId, - user_id: null, + user_id: testUser.id, organization_id: testOrganization.id, }); }); diff --git a/apps/web/src/lib/organizations/organization-billing.ts b/apps/web/src/lib/organizations/organization-billing.ts index 1c04c4c8e5..a7279ec2d5 100644 --- a/apps/web/src/lib/organizations/organization-billing.ts +++ b/apps/web/src/lib/organizations/organization-billing.ts @@ -80,6 +80,9 @@ export async function getOrCreateStripeCustomerIdForOrganization( type Config = StripeConfig; type ProcessTopupForOrganizationOptions = { isAutoTopUp?: boolean; + serviceFeeCents?: number; + grossPaidCents?: number; + creditsCents?: number; }; const ORGANIZATION_CREDITS_TOP_UP_CONFIRMATION_EMAIL_TYPE = 'organization_credits_top_up_confirmation'; @@ -110,6 +113,9 @@ export async function maybeSendOrganizationTopUpConfirmationEmail(params: { userId: User['id']; organization: Organization; amountInCents: number; + serviceFeeCents?: number; + grossPaidCents?: number; + creditsCents?: number; stripeChargeOrInvoiceId: string; isAutoTopUp: boolean; purchaseDate?: Date; @@ -118,6 +124,9 @@ export async function maybeSendOrganizationTopUpConfirmationEmail(params: { userId, organization, amountInCents, + serviceFeeCents = 0, + grossPaidCents = amountInCents, + creditsCents = amountInCents, stripeChargeOrInvoiceId, isAutoTopUp, purchaseDate, @@ -223,8 +232,10 @@ export async function maybeSendOrganizationTopUpConfirmationEmail(params: { const sendResult = await sendCreditsTopUpEmail({ to: recipient.email, variant: isAutoTopUp ? 'org_auto' : 'org_manual', - amountCents: amountInCents, - creditsCents: amountInCents, + principalCents: amountInCents, + serviceFeeCents, + grossPaidCents, + creditsCents, purchaseDate: purchaseDate ?? new Date(), receiptUrl, organizationId: organization.id, @@ -345,7 +356,12 @@ export async function processTopupForOrganization( config: Config, options: ProcessTopupForOrganizationOptions = {} ) { - const { isAutoTopUp = false } = options; + const { + isAutoTopUp = false, + serviceFeeCents = 0, + grossPaidCents = amountInCents, + creditsCents = amountInCents, + } = options; const organization = await getOrganizationById(organizationId); if (!organization) throw new Error('Organization not found: ' + organizationId); @@ -422,6 +438,9 @@ export async function processTopupForOrganization( userId: kiloUserId, organization, amountInCents, + serviceFeeCents, + grossPaidCents, + creditsCents, stripeChargeOrInvoiceId: config.stripe_payment_id, isAutoTopUp, purchaseDate: new Date(existingCreditTransaction.createdAt), @@ -443,6 +462,9 @@ export async function processTopupForOrganization( userId: kiloUserId, organization, amountInCents, + serviceFeeCents, + grossPaidCents, + creditsCents, stripeChargeOrInvoiceId: config.stripe_payment_id, isAutoTopUp, }); @@ -452,6 +474,9 @@ async function recoverOrganizationTopUpConfirmationEmailIfMissing(params: { userId: User['id']; organization: Organization; amountInCents: number; + serviceFeeCents?: number; + grossPaidCents?: number; + creditsCents?: number; stripeChargeOrInvoiceId: string; isAutoTopUp: boolean; purchaseDate: Date; @@ -500,6 +525,9 @@ async function scheduleOrganizationTopUpConfirmationEmail(params: { userId: User['id']; organization: Organization; amountInCents: number; + serviceFeeCents?: number; + grossPaidCents?: number; + creditsCents?: number; stripeChargeOrInvoiceId: string; isAutoTopUp: boolean; purchaseDate?: Date; diff --git a/apps/web/src/lib/purchase-emails.test.ts b/apps/web/src/lib/purchase-emails.test.ts index c1d8883314..79f5ff6ee1 100644 --- a/apps/web/src/lib/purchase-emails.test.ts +++ b/apps/web/src/lib/purchase-emails.test.ts @@ -24,7 +24,10 @@ import { import type * as creditBillingModule from '@/lib/kiloclaw/credit-billing'; import { renderTemplate, + buildCreditsTopUpCreditsRowSection, + buildCreditsTopUpInfoRow, buildCreditsTopUpReceiptSection, + buildCreditsTopUpServiceFeeSection, subjects, sendCreditsTopUpEmail, sendKiloClawSubscriptionStartedEmail, @@ -91,40 +94,105 @@ const stripeChargeRetrieveMock = jest.mocked(stripeClient.charges.retrieve); const stripeInvoiceRetrieveMock = jest.mocked(stripeClient.invoices.retrieve); const stripePaymentIntentRetrieveMock = jest.mocked(stripeClient.paymentIntents.retrieve); +function feeFreeTopUpCents(cents: number) { + return { + principalCents: cents, + serviceFeeCents: 0, + grossPaidCents: cents, + creditsCents: cents, + }; +} + +function creditsTopUpTemplateVars( + overrides: Partial<{ + heading: string; + intro: string; + amount_label: string; + amount_usd: string; + service_fee_section: ReturnType; + credits_row_section: ReturnType; + purchase_date: string; + credits_url: string; + receipt_section: ReturnType; + year: string; + }> = {} +) { + return { + heading: 'Thanks for your top-up', + intro: 'hello', + amount_label: 'Amount', + amount_usd: '15.00', + service_fee_section: buildCreditsTopUpServiceFeeSection({ + serviceFeeCents: 0, + grossPaidCents: 1500, + }), + credits_row_section: buildCreditsTopUpCreditsRowSection({ + creditsCents: 1500, + hasServiceFee: false, + }), + purchase_date: 'January 1, 2026', + credits_url: 'https://app.kilocode.ai/credits', + receipt_section: buildCreditsTopUpReceiptSection('https://stripe.test/receipt'), + year: '2026', + ...overrides, + }; +} + describe('creditsTopUp template', () => { test('renders required fields', () => { - const html = renderTemplate('creditsTopUp', { - heading: 'Thanks for your top-up', - intro: 'hello', - amount_usd: '15.00', - credits_usd: '15.00', - purchase_date: 'January 1, 2026', - credits_url: 'https://app.kilocode.ai/credits', - receipt_section: buildCreditsTopUpReceiptSection('https://stripe.test/receipt'), - year: '2026', - }); + const html = renderTemplate('creditsTopUp', creditsTopUpTemplateVars()); expect(html).toContain('Thanks for your top-up'); - expect(html).toContain('$15.00'); + expect(html).toContain('Amount:'); + expect(html).toContain('$15.00 USD'); + expect(html).toContain('Credits: $15.00 USD'); expect(html).toContain('January 1, 2026'); expect(html).toContain('https://app.kilocode.ai/credits'); expect(html).toContain('https://stripe.test/receipt'); + expect(html).not.toContain('Service fee (5%)'); + expect(html).not.toContain('Credit principal'); + expect(html).not.toContain('Total paid'); }); test('omits receipt section when receipt URL is missing', () => { - const html = renderTemplate('creditsTopUp', { - heading: 'h', - intro: 'i', - amount_usd: '5.00', - credits_usd: '5.00', - purchase_date: 'January 1, 2026', - credits_url: 'https://app.kilocode.ai/credits', - receipt_section: buildCreditsTopUpReceiptSection(null), - year: '2026', - }); + const html = renderTemplate( + 'creditsTopUp', + creditsTopUpTemplateVars({ + heading: 'h', + intro: 'i', + amount_usd: '5.00', + receipt_section: buildCreditsTopUpReceiptSection(null), + }) + ); expect(html).not.toContain('View your Stripe receipt'); }); + + test('injects escaped service-fee rows without template conditionals', () => { + const html = renderTemplate( + 'creditsTopUp', + creditsTopUpTemplateVars({ + amount_label: 'Credits added', + amount_usd: '20.00', + service_fee_section: buildCreditsTopUpServiceFeeSection({ + serviceFeeCents: 100, + grossPaidCents: 2100, + }), + credits_row_section: buildCreditsTopUpCreditsRowSection({ + creditsCents: 2000, + hasServiceFee: true, + }), + }) + ); + + expect(html).toContain('Credits added:'); + expect(html).toContain('$20.00 USD'); + expect(html).toContain('Service fee (5%): $1.00 USD'); + expect(html).toContain('Total paid: $21.00 USD'); + expect(html).not.toContain('Credits:'); + expect(html).not.toContain('Credit principal'); + expect(html).not.toContain('Amount:'); + }); }); describe('subjects map', () => { @@ -643,8 +711,7 @@ describe('sendCreditsTopUpEmail payload', () => { const result = await sendCreditsTopUpEmail({ to: 'recipient@example.com', variant: 'manual', - amountCents: 1500, - creditsCents: 1500, + ...feeFreeTopUpCents(1500), purchaseDate: new Date('2026-01-15T12:00:00Z'), receiptUrl: 'https://pay.stripe.com/receipts/abc', }); @@ -654,19 +721,114 @@ describe('sendCreditsTopUpEmail payload', () => { const [params] = sendViaMailgunMock.mock.calls[0]; expect(params.to).toBe('recipient@example.com'); expect(params.subject).toBe(subjects.creditsTopUp); + expect(params.html).toContain('Amount:'); expect(params.html).toContain('$15.00 USD'); + expect(params.html).toContain('Credits: $15.00 USD'); expect(params.html).toContain('January 15, 2026'); expect(params.html).toContain('/credits'); expect(params.html).toContain('https://pay.stripe.com/receipts/abc'); expect(params.html).toContain('View your Stripe receipt'); + expect(params.html).not.toContain('Service fee (5%)'); + expect(params.html).not.toContain('Credit principal'); + expect(params.html).not.toContain('Total paid'); + }); + + test('positive service fee renders credits added, fee, total paid, and date', async () => { + await sendCreditsTopUpEmail({ + to: 'recipient@example.com', + variant: 'manual', + principalCents: 2000, + serviceFeeCents: 100, + grossPaidCents: 2100, + creditsCents: 2000, + purchaseDate: new Date('2026-01-15T12:00:00Z'), + receiptUrl: null, + }); + + const [params] = sendViaMailgunMock.mock.calls[0]; + expect(params.html).toContain('Credits added:'); + expect(params.html).toContain( + 'Service fee (5%): $1.00 USD' + ); + expect(params.html).toContain('Total paid: $21.00 USD'); + expect(params.html).toContain('$20.00 USD'); + expect(params.html).not.toContain('Credits:'); + expect(params.html).toContain('Date: January 15, 2026'); + expect(params.html).not.toContain('Amount:'); + expect(params.html).not.toContain('Credit principal'); + }); + + test('fee-free top-ups omit the fee row without exposing an exemption reason', async () => { + const feeSection = buildCreditsTopUpServiceFeeSection({ + serviceFeeCents: 0, + grossPaidCents: 1500, + }); + expect(feeSection.html).toBe(''); + + await sendCreditsTopUpEmail({ + to: 'recipient@example.com', + variant: 'manual', + ...feeFreeTopUpCents(1500), + purchaseDate: new Date('2026-01-15T12:00:00Z'), + receiptUrl: null, + }); + + const [params] = sendViaMailgunMock.mock.calls[0]; + expect(params.html).toContain('Amount:'); + expect(params.html).toContain('$15.00 USD'); + expect(params.html).toContain('Credits: $15.00 USD'); + expect(params.html).not.toContain('Service fee (5%)'); + expect(params.html).not.toContain('Credit principal'); + expect(params.html).not.toContain('Total paid'); + expect(params.html).not.toContain('exempt'); + expect(params.html).not.toContain('waiver'); + expect(params.html).not.toContain('failed to apply'); + }); + + test('escapes organization names, receipt URLs, and optional fee-row interpolations', async () => { + const injectedRow = buildCreditsTopUpInfoRow( + 'Service fee (5%)', + '$1.00 USD & more' + ); + expect(injectedRow).toContain('Service fee (5%)<script>alert(1)</script>'); + expect(injectedRow).toContain('$1.00 USD & more'); + expect(injectedRow).not.toContain(''); + + const receipt = buildCreditsTopUpReceiptSection( + 'https://stripe.test/receipt?q=">' + ); + expect(receipt.html).toContain('"'); + expect(receipt.html).toContain('<script>'); + expect(receipt.html).not.toContain(''); + + await sendCreditsTopUpEmail({ + to: 'billing@example.com', + variant: 'org_manual', + principalCents: 2000, + serviceFeeCents: 100, + grossPaidCents: 2100, + creditsCents: 2000, + purchaseDate: new Date('2026-04-01T00:00:00Z'), + receiptUrl: 'https://pay.stripe.com/receipts/" onclick="alert(1)', + organizationId: 'org_123', + organizationName: 'Acme ', + }); + + const [params] = sendViaMailgunMock.mock.calls[0]; + expect(params.html).toContain('Acme <script>alert(1)</script>'); + expect(params.html).not.toContain(''); + expect(params.html).toContain('"'); + expect(params.html).not.toContain('" onclick="alert(1)'); + expect(params.html).toContain( + 'Service fee (5%): $1.00 USD' + ); }); test('auto variant overrides the subject and swaps the heading copy', async () => { await sendCreditsTopUpEmail({ to: 'recipient@example.com', variant: 'auto', - amountCents: 2000, - creditsCents: 2000, + ...feeFreeTopUpCents(2000), purchaseDate: new Date('2026-02-01T00:00:00Z'), receiptUrl: null, }); @@ -681,8 +843,7 @@ describe('sendCreditsTopUpEmail payload', () => { await sendCreditsTopUpEmail({ to: 'billing@example.com', variant: 'org_manual', - amountCents: 2500, - creditsCents: 2500, + ...feeFreeTopUpCents(2500), purchaseDate: new Date('2026-04-01T00:00:00Z'), receiptUrl: 'https://pay.stripe.com/receipts/org-manual', organizationId: 'org_123', @@ -705,8 +866,7 @@ describe('sendCreditsTopUpEmail payload', () => { await sendCreditsTopUpEmail({ to: 'billing@example.com', variant: 'org_auto', - amountCents: 4000, - creditsCents: 4000, + ...feeFreeTopUpCents(4000), purchaseDate: new Date('2026-05-01T00:00:00Z'), receiptUrl: null, organizationId: 'org_456', @@ -729,8 +889,7 @@ describe('sendCreditsTopUpEmail payload', () => { const invalidOrganizationTopUpEmailParams: Parameters[0] = { to: 'billing@example.com', variant: 'org_manual', - amountCents: 2500, - creditsCents: 2500, + ...feeFreeTopUpCents(2500), purchaseDate: new Date('2026-04-01T00:00:00Z'), receiptUrl: null, organizationName: 'Acme Labs', @@ -745,8 +904,7 @@ describe('sendCreditsTopUpEmail payload', () => { sendCreditsTopUpEmail({ to: 'billing@example.com', variant: 'org_manual', - amountCents: 2500, - creditsCents: 2500, + ...feeFreeTopUpCents(2500), purchaseDate: new Date('2026-04-01T00:00:00Z'), receiptUrl: null, organizationName: 'Acme Labs', @@ -760,8 +918,7 @@ describe('sendCreditsTopUpEmail payload', () => { await sendCreditsTopUpEmail({ to: 'billing@example.com', variant: 'org_manual', - amountCents: 2500, - creditsCents: 2500, + ...feeFreeTopUpCents(2500), purchaseDate: new Date('2026-04-01T00:00:00Z'), receiptUrl: null, creditsUrl: '', @@ -780,8 +937,7 @@ describe('sendCreditsTopUpEmail payload', () => { await sendCreditsTopUpEmail({ to: 'recipient@example.com', variant: 'manual', - amountCents: 500, - creditsCents: 500, + ...feeFreeTopUpCents(500), purchaseDate: new Date('2026-03-01T00:00:00Z'), receiptUrl: null, }); @@ -797,8 +953,7 @@ describe('sendCreditsTopUpEmail payload', () => { const result = await sendCreditsTopUpEmail({ to: 'bad@example.com', variant: 'manual', - amountCents: 1000, - creditsCents: 1000, + ...feeFreeTopUpCents(1000), purchaseDate: new Date(), receiptUrl: null, }); @@ -813,8 +968,7 @@ describe('sendCreditsTopUpEmail payload', () => { const result = await sendCreditsTopUpEmail({ to: 'recipient@example.com', variant: 'manual', - amountCents: 1000, - creditsCents: 1000, + ...feeFreeTopUpCents(1000), purchaseDate: new Date(), receiptUrl: null, }); diff --git a/apps/web/src/lib/service-fees/settlement.test.ts b/apps/web/src/lib/service-fees/settlement.test.ts index 517b366f0f..dbe907ccb4 100644 --- a/apps/web/src/lib/service-fees/settlement.test.ts +++ b/apps/web/src/lib/service-fees/settlement.test.ts @@ -722,6 +722,30 @@ describe('settleKiloPassInvoiceServiceFee', () => { }); }); + test('pre-activation invoice without an assessment does not alert', async () => { + const store = createMemorySettlementStore(); + const sendAlert: NonNullable = jest.fn( + async () => undefined + ); + const result = await settleKiloPassInvoiceServiceFee({ + invoice: paidInvoice([pricedLine(KILO_PASS_PRICE_ID, 4_900)], { + id: 'in_legacy', + amount_paid: 4_900, + created: ACTIVATION - 1, + }), + stripe: { invoices: { listLineItems: async () => ({ data: [], has_more: false }) } }, + store, + deps: { sendAlert }, + }); + + expect(result).toMatchObject({ + status: 'ignored', + settledProductMinor: 4_900, + assessment: null, + }); + expect(sendAlert).not.toHaveBeenCalled(); + }); + test('missing assessment ignores fee revenue and returns product-only amount', async () => { const store = createMemorySettlementStore(); const sendAlert: NonNullable = jest.fn( diff --git a/apps/web/src/lib/service-fees/settlement.ts b/apps/web/src/lib/service-fees/settlement.ts index 50e92f18e5..6969291414 100644 --- a/apps/web/src/lib/service-fees/settlement.ts +++ b/apps/web/src/lib/service-fees/settlement.ts @@ -20,6 +20,7 @@ import { calculateServiceFeeMinor, getNetPretaxLineAmountMinor, } from '@/lib/service-fees/calculation'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; import { createInvoiceServiceFeeAssessmentKey } from '@/lib/service-fees/checkout'; import { applyDeferredServiceFeeRefunds } from '@/lib/service-fees/refunds'; import { @@ -105,6 +106,15 @@ export async function settleKiloPassInvoiceServiceFee(params: { subscription, }); if (!assessment) { + if (params.invoice.created < SERVICE_FEE_ACTIVATION_UNIX_SECONDS) { + return { + status: 'ignored', + settledProductMinor: productOnlyMinor, + chargedFeeMinor: 0, + grossPaidMinor: Math.max(0, params.invoice.amount_paid ?? 0), + assessment: null, + }; + } await alertSafely({ assessmentKey: createInvoiceServiceFeeAssessmentKey(invoiceId), flow: 'personal_kilo_pass', diff --git a/apps/web/src/lib/stripe/index.test.ts b/apps/web/src/lib/stripe/index.test.ts index bdfb618ee4..7cf4f22e0f 100644 --- a/apps/web/src/lib/stripe/index.test.ts +++ b/apps/web/src/lib/stripe/index.test.ts @@ -15,7 +15,7 @@ const CURRENT_KILOCLAW_STANDARD_PRICE_ID = const CURRENT_KILO_PASS_TIER_19_MONTHLY_PRICE_ID = process.env.STRIPE_KILO_PASS_TIER_19_MONTHLY_PRICE_ID ?? 'price_test_kilo_pass_tier_19_monthly'; -import { describe, test, expect, beforeEach } from '@jest/globals'; +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; import type * as creditsModule from '@/lib/credits'; import type * as organizationBillingModule from '@/lib/organizations/organization-billing'; @@ -113,6 +113,7 @@ import { cleanupDbForTest } from '@/lib/drizzle'; import { processTopUp } from '@/lib/credits'; import { processTopupForOrganization } from '@/lib/organizations/organization-billing'; import { reportEvents } from '@/lib/ai-gateway/abuse-service'; +import { SERVICE_FEE_ACTIVATION_UNIX_SECONDS } from '@/lib/service-fees/constants'; const reportEventsMock = jest.mocked(reportEvents); @@ -3067,8 +3068,18 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', id: params.id, amount: params.amount, customer: params.customer, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, }) as unknown as Stripe.Charge; + const withTopUpPrincipalMetadata = ( + metadata: StripeTopupMetadata, + principalMinor: number + ): StripeTopupMetadata => ({ + ...metadata, + amountCents: String(principalMinor), + serviceFeePrincipalMinor: String(principalMinor), + }); + const makePaymentIntent = (params: { id: string; metadata: StripeTopupMetadata; @@ -3082,6 +3093,22 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', payment_method: params.payment_method ?? null, }) as unknown as Stripe.PaymentIntent; + let listCheckoutSessionsSpy: { mockRestore: () => void } | undefined; + + beforeEach(async () => { + const { client } = await import('@/lib/stripe-client'); + listCheckoutSessionsSpy = jest.spyOn(client.checkout.sessions, 'list').mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/checkout/sessions', + } as unknown as Stripe.Response>); + }); + + afterEach(() => { + listCheckoutSessionsSpy?.mockRestore(); + }); + test('both organizationId and kiloUserId: processes organization top-up; uses kiloUserId', async () => { const user = await insertTestUser(); const org = await createOrganization('Org-Both', user.id); @@ -3092,7 +3119,10 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', const charge = makeCharge({ id: chId, amount: amountInCents, customer: 'cus_irrelevant' }); const paymentIntent = makePaymentIntent({ id: piId, - metadata: { organizationId: org.id, kiloUserId: user.id }, + metadata: withTopUpPrincipalMetadata( + { organizationId: org.id, kiloUserId: user.id }, + amountInCents + ), }); await handleSuccessfulChargeWithPayment(charge, paymentIntent); @@ -3132,11 +3162,14 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', }); const paymentIntent = makePaymentIntent({ id: paymentIntentId, - metadata: { - type: 'org-auto-topup-setup', - kiloUserId: user.id, - organizationId: org.id, - }, + metadata: withTopUpPrincipalMetadata( + { + type: 'org-auto-topup-setup', + kiloUserId: user.id, + organizationId: org.id, + }, + amountInCents + ), }); const processOrgTopUpMock = processTopupForOrganization as jest.MockedFunction< @@ -3151,7 +3184,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', org.id, amountInCents, { type: 'stripe', stripe_payment_id: paymentIntentId }, - { isAutoTopUp: true } + { isAutoTopUp: true, serviceFeeCents: 0, grossPaidCents: amountInCents } ); }); @@ -3197,7 +3230,10 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', // Mark as stripe-checkout-driven top-up, no card details to avoid free-credits flow side effects const paymentIntent = makePaymentIntent({ id: paymentIntentId, - metadata: { kiloUserId: user.id, type: 'stripe-checkout-topup' }, + metadata: withTopUpPrincipalMetadata( + { kiloUserId: user.id, type: 'stripe-checkout-topup' }, + amountInCents + ), payment_method: null, }); @@ -3268,6 +3304,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', type: 'auto-topup-setup', kiloUserId: user.id, amountCents: '2000', + serviceFeePrincipalMinor: '2000', }, } as unknown as Stripe.PaymentIntent; @@ -3297,6 +3334,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', type: 'auto-topup-setup', kiloUserId: user.id, amountCents: '5000', + serviceFeePrincipalMinor: '5000', }, } as unknown as Stripe.PaymentIntent; @@ -3356,6 +3394,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', kiloUserId: user.id, organizationId: org.id, amountCents: '2000', + serviceFeePrincipalMinor: '2000', }, } as unknown as Stripe.PaymentIntent; @@ -3387,6 +3426,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', kiloUserId: user.id, organizationId: org.id, amountCents: '5000', + serviceFeePrincipalMinor: '5000', }, } as unknown as Stripe.PaymentIntent; @@ -3447,6 +3487,7 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', kiloUserId: user.id, organizationId: org.id, amountCents: '3000', + serviceFeePrincipalMinor: '3000', }, } as unknown as Stripe.PaymentIntent; @@ -3489,7 +3530,13 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', object: 'invoice', charge: userChargeId, amount_paid: 5000, - metadata: { type: 'auto-topup', kiloUserId: user.id }, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1, + metadata: { + type: 'auto-topup', + kiloUserId: user.id, + amountCents: '5000', + serviceFeePrincipalMinor: '5000', + }, } as unknown as Stripe.Invoice, previous_attributes: {}, }, @@ -3538,7 +3585,13 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', object: 'invoice', charge: orgChargeId, amount_paid: 5000, - metadata: { type: 'org-auto-topup', organizationId: org.id }, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1, + metadata: { + type: 'org-auto-topup', + organizationId: org.id, + amountCents: '5000', + serviceFeePrincipalMinor: '5000', + }, } as unknown as Stripe.Invoice, previous_attributes: {}, }, @@ -3587,7 +3640,13 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', object: 'invoice', charge: orgChargeId, amount_paid: 5000, - metadata: { type: 'org-auto-topup', organizationId: org.id }, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS - 1, + metadata: { + type: 'org-auto-topup', + organizationId: org.id, + amountCents: '5000', + serviceFeePrincipalMinor: '5000', + }, } as unknown as Stripe.Invoice, previous_attributes: {}, }, @@ -3601,13 +3660,98 @@ describe('handleSuccessfulChargeWithPayment (org/user routing & side-effects)', org.id, 5000, { type: 'stripe', stripe_payment_id: orgChargeId }, - { isAutoTopUp: true } + { isAutoTopUp: true, serviceFeeCents: 0, grossPaidCents: 5000 } ); } finally { processOrgTopUpMock.mockRestore(); } }); + test('credits trusted principal, not gross charge or invoice amount', async () => { + await cleanupDbForTest(); + + const user = await insertTestUser(); + const org = await createOrganization('Org Principal Vs Gross', user.id); + const principalMinor = 10_000; + const grossPaidMinor = 10_500; + const paymentIntentId = `pi_principal_${Math.random()}`; + const chargeId = `ch_principal_${Math.random()}`; + + const processOrgTopUpMock = processTopupForOrganization as jest.MockedFunction< + typeof processTopupForOrganization + >; + processOrgTopUpMock.mockResolvedValueOnce(undefined); + + try { + await handleSuccessfulChargeWithPayment( + makeCharge({ id: chargeId, amount: grossPaidMinor, customer: user.stripe_customer_id }), + makePaymentIntent({ + id: paymentIntentId, + metadata: withTopUpPrincipalMetadata( + { + type: 'stripe-checkout-topup', + kiloUserId: user.id, + organizationId: org.id, + }, + principalMinor + ), + }) + ); + + expect(processOrgTopUpMock).toHaveBeenCalledWith( + user.id, + org.id, + principalMinor, + { type: 'stripe', stripe_payment_id: paymentIntentId }, + { isAutoTopUp: false, serviceFeeCents: 0, grossPaidCents: grossPaidMinor } + ); + } finally { + processOrgTopUpMock.mockRestore(); + } + + const processTopUpMock = processTopUp as jest.MockedFunction; + processTopUpMock.mockResolvedValueOnce(true); + const invoiceChargeId = `ch_invoice_principal_${Math.random()}`; + await db.insert(auto_top_up_configs).values({ + owned_by_user_id: user.id, + stripe_payment_method_id: `pm_principal_${Math.random()}`, + amount_cents: principalMinor, + attempt_started_at: new Date().toISOString(), + }); + + try { + await processStripePaymentEventHook({ + ...baseStripeEvent(), + type: 'invoice.paid', + data: { + object: { + id: `in_principal_${Math.random()}`, + object: 'invoice', + charge: invoiceChargeId, + amount_paid: grossPaidMinor, + created: SERVICE_FEE_ACTIVATION_UNIX_SECONDS, + metadata: { + type: 'auto-topup', + kiloUserId: user.id, + amountCents: String(principalMinor), + serviceFeePrincipalMinor: String(principalMinor), + }, + } as unknown as Stripe.Invoice, + previous_attributes: {}, + }, + }); + + expect(processTopUpMock).toHaveBeenCalledWith( + expect.objectContaining({ id: user.id }), + principalMinor, + { type: 'stripe', stripe_payment_id: invoiceChargeId }, + { isAutoTopUp: true, serviceFeeCents: 0, grossPaidCents: grossPaidMinor } + ); + } finally { + processTopUpMock.mockRestore(); + } + }); + test('invoice.paid dispatches zero-dollar KiloClaw invoices to settlement', async () => { const handleKiloClawInvoicePaid = jest.fn< Promise, diff --git a/apps/web/src/lib/stripe/index.ts b/apps/web/src/lib/stripe/index.ts index 817dd6dcfa..c29205e987 100644 --- a/apps/web/src/lib/stripe/index.ts +++ b/apps/web/src/lib/stripe/index.ts @@ -82,6 +82,14 @@ import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; import { successResult } from '@/lib/maybe-result'; import { observeStripeEarlyFraudWarningCreated } from '@/lib/stripe/early-fraud-warning'; import { observeStripeDisputeCreated } from '@/lib/stripe/disputes'; +import { + resolveFixedUsdPriceUnitAmount, + settleTrustedAutoTopUpInvoice, + settleTrustedTopUpCharge, + type ServiceFeeCheckoutDependencies, +} from '@/lib/service-fees/checkout'; +import { createServiceFeeStores } from '@/lib/service-fees/drizzle-store'; +import { getEffectiveOrganizationServiceFeeExemption } from '@/lib/service-fees/organization-exemptions'; type KiloClawChargeContext = { chargeId: string; @@ -237,8 +245,43 @@ export type StripeTopupMetadata = { type?: string; kiloUserId?: User['id']; organizationId?: Organization['id'] | null; + amountCents?: string; + serviceFeeAssessmentKey?: string; + serviceFeeVersion?: string; + serviceFeeFlow?: string; + serviceFeePrincipalMinor?: string; + serviceFeeOrganizationId?: string; }; +function createStripeTopUpFeeDeps(): 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), + createInvoiceItem: itemParams => client.invoiceItems.create(itemParams), + retrieveCheckoutSessionCreated: async paymentIntentId => { + try { + const listed = await client.checkout.sessions.list({ + payment_intent: paymentIntentId, + limit: 1, + }); + return listed.data[0]?.created ?? null; + } catch { + return null; + } + }, + }; +} + export async function detachAllPaymentMethods(user: User) { const paymentMethods = await client.paymentMethods.list({ customer: user.stripe_customer_id, @@ -394,7 +437,8 @@ async function handleAutoTopUpSetup( user: User, paymentIntent: Stripe.PaymentIntent, creditAmountInCents: number, - config: StripeConfig + config: StripeConfig, + emailAmounts: { serviceFeeCents?: number; grossPaidCents?: number } = {} ) { logExceptInTest( `Processing auto-topup-setup for user ${user.id} from payment intent ${paymentIntent.id}` @@ -451,7 +495,10 @@ async function handleAutoTopUpSetup( .where(eq(kilocode_users.id, user.id)); // Credit the initial payment to the user's balance - const setupTopUpOk = await processTopUp(user, creditAmountInCents, config); + const setupTopUpOk = await processTopUp(user, creditAmountInCents, config, { + serviceFeeCents: emailAmounts.serviceFeeCents, + grossPaidCents: emailAmounts.grossPaidCents, + }); if (!setupTopUpOk) { sentryLogger('stripe', 'info')('Auto-topup-setup already registered or failed to insert', { kilo_user_id: user.id, @@ -536,7 +583,7 @@ export async function handleSuccessfulChargeWithPayment( // NOTE: (bmc) PLEASE NOTE this is called for ALL successful charges, including subscriptions and org purchases // the user topup flow and credit application stuff should only apply the charge is not from an organization const config: StripeConfig = { type: 'stripe', stripe_payment_id: charge.id }; - const creditAmountInCents = charge.amount; + const feeDeps = createStripeTopUpFeeDeps(); const organizationId = paymentIntent.metadata.organizationId; const kiloUserId = paymentIntent.metadata.kiloUserId; @@ -555,9 +602,31 @@ export async function handleSuccessfulChargeWithPayment( `Processing top-up for organization ${organizationId} from charge ${charge.id}` ); config.stripe_payment_id = paymentIntent.id; - await processTopupForOrganization(kiloUserId, organizationId, creditAmountInCents, config, { - isAutoTopUp: paymentIntent.metadata.type === 'org-auto-topup-setup', + const settlement = await settleTrustedTopUpCharge({ + charge, + paymentIntent, + kiloUserId, + organizationId, + flowHint: + paymentIntent.metadata.type === 'org-auto-topup-setup' + ? 'organization_auto_top_up_setup' + : 'organization_top_up', + deps: feeDeps, }); + if (!settlement.shouldCredit) { + return; + } + await processTopupForOrganization( + kiloUserId, + organizationId, + settlement.principalMinor, + config, + { + isAutoTopUp: paymentIntent.metadata.type === 'org-auto-topup-setup', + serviceFeeCents: settlement.chargedFeeMinor, + grossPaidCents: settlement.grossPaidMinor, + } + ); if (paymentIntent.metadata.type === 'org-auto-topup-setup') { await handleOrgAutoTopUpSetup(organizationId, kiloUserId, paymentIntent, config); @@ -611,7 +680,20 @@ export async function handleSuccessfulChargeWithPayment( `Skipping invoice-based charge ${charge.id} in charge.succeeded - will be handled by invoice.payment_succeeded` ); } else if (isAutoTopUpSetup) { - await handleAutoTopUpSetup(user, paymentIntent, creditAmountInCents, config); + const settlement = await settleTrustedTopUpCharge({ + charge, + paymentIntent, + kiloUserId: user.id, + flowHint: 'personal_auto_top_up_setup', + deps: feeDeps, + }); + if (!settlement.shouldCredit) { + return; + } + await handleAutoTopUpSetup(user, paymentIntent, settlement.principalMinor, config, { + serviceFeeCents: settlement.chargedFeeMinor, + grossPaidCents: settlement.grossPaidMinor, + }); } else if (isKiloclawEarlybird) { await recordKiloclawEarlybirdPurchase(user, charge); } else { @@ -626,7 +708,20 @@ export async function handleSuccessfulChargeWithPayment( return; } - const topUpOk = await processTopUp(user, creditAmountInCents, config); + const settlement = await settleTrustedTopUpCharge({ + charge, + paymentIntent, + kiloUserId: user.id, + flowHint: 'personal_top_up', + deps: feeDeps, + }); + if (!settlement.shouldCredit) { + return; + } + const topUpOk = await processTopUp(user, settlement.principalMinor, config, { + serviceFeeCents: settlement.chargedFeeMinor, + grossPaidCents: settlement.grossPaidMinor, + }); if (!topUpOk) { sentryLogger('stripe', 'warning')('Ignoring already registered top-up', { kilo_user_id: user.id, @@ -900,8 +995,21 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { traceId, }); - const autoTopUpOk = await processTopUp(user, invoice.amount_paid, config, { + const settlement = await settleTrustedAutoTopUpInvoice({ + invoice, + chargeId, + kiloUserId: user.id, + flow: 'personal_auto_top_up', + deps: createStripeTopUpFeeDeps(), + }); + if (!settlement.shouldCredit) { + break; + } + + const autoTopUpOk = await processTopUp(user, settlement.principalMinor, config, { isAutoTopUp: true, + serviceFeeCents: settlement.chargedFeeMinor, + grossPaidCents: settlement.grossPaidMinor, }); if (!autoTopUpOk) { @@ -955,12 +1063,31 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { columns: { created_by_user_id: true }, }); + const initiatingUserId = + autoTopUpConfig?.created_by_user_id ?? SYSTEM_AUTO_TOP_UP_USER_ID; + const settlement = await settleTrustedAutoTopUpInvoice({ + invoice, + chargeId, + kiloUserId: + initiatingUserId === SYSTEM_AUTO_TOP_UP_USER_ID ? undefined : initiatingUserId, + organizationId, + flow: 'organization_auto_top_up', + deps: createStripeTopUpFeeDeps(), + }); + if (!settlement.shouldCredit) { + break; + } + await processTopupForOrganization( - autoTopUpConfig?.created_by_user_id ?? SYSTEM_AUTO_TOP_UP_USER_ID, + initiatingUserId, organizationId, - invoice.amount_paid, + settlement.principalMinor, config, - { isAutoTopUp: true } + { + isAutoTopUp: true, + serviceFeeCents: settlement.chargedFeeMinor, + grossPaidCents: settlement.grossPaidMinor, + } ); processedSuccessfully = true; @@ -1499,6 +1626,13 @@ export async function getStripeTopUpCheckoutUrl( /** Optional internal path to redirect to when the user cancels checkout. */ cancelPath?: string | null ): Promise { + const defaultPriceId = amount ? null : getEnvVariable('STRIPE_TOP_UP_PRICE_ID'); + const principalMinor = amount + ? Math.round(amount * 100) + : await resolveFixedUsdPriceUnitAmount({ + stripe: client, + priceId: defaultPriceId as string, + }); const line_items = amount ? [ { @@ -1507,14 +1641,14 @@ export async function getStripeTopUpCheckoutUrl( product_data: { name: 'Kilo Balance Top Up', }, - unit_amount: Math.round(amount * 100), // Convert dollars to cents + unit_amount: principalMinor, }, quantity: 1, }, ] : [ { - price: getEnvVariable('STRIPE_TOP_UP_PRICE_ID'), + price: defaultPriceId as string, quantity: 1, }, ]; @@ -1552,6 +1686,8 @@ export async function getStripeTopUpCheckoutUrl( type: 'stripe-checkout-topup', kiloUserId, organizationId: organizationId ?? null, + amountCents: String(principalMinor), + serviceFeePrincipalMinor: String(principalMinor), } satisfies StripeTopupMetadata, }, saved_payment_method_options: { diff --git a/apps/web/src/routers/admin/email-testing-router.ts b/apps/web/src/routers/admin/email-testing-router.ts index f4e0f65e8f..b784493fbd 100644 --- a/apps/web/src/routers/admin/email-testing-router.ts +++ b/apps/web/src/routers/admin/email-testing-router.ts @@ -6,7 +6,9 @@ import { verifyEmail } from '@/lib/email-neverbounce'; import { subjects, creditsVars, + buildCreditsTopUpCreditsRowSection, buildCreditsTopUpReceiptSection, + buildCreditsTopUpServiceFeeSection, RawHtml, renderTemplate, type TemplateName, @@ -187,8 +189,16 @@ function fixtureTemplateVars(template: TemplateName): Record