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