Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .specs/impact-affiliate-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/emails/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | — |
Expand Down
17 changes: 3 additions & 14 deletions apps/web/src/emails/creditsTopUp.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,10 @@
sans-serif;
"
>
<strong style="color: #1a1a1a">Amount:</strong> ${{ amount_usd }} USD
</p>
<p
style="
margin: 0 0 8px;
font-size: 13px;
line-height: 20px;
color: #555;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
<strong style="color: #1a1a1a">Credits:</strong> ${{ credits_usd }} USD
<strong style="color: #1a1a1a">{{ amount_label }}:</strong>
${{ amount_usd }} USD
</p>
{{ service_fee_section }} {{ credits_row_section }}
<p
style="
margin: 0;
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/lib/autoTopUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,22 @@ async function performAutoTopUpForEntity(
// Credit application is handled by the `invoice.paid` webhook.
const invoiceMetadata: Record<string, string> =
entity.type === 'user'
? { type: 'auto-topup', kiloUserId: entity.user.id, traceId }
: { type: 'org-auto-topup', organizationId: entity.organization.id, traceId };
? {
type: 'auto-topup',
kiloUserId: entity.user.id,
traceId,
amountCents: String(amountCents),
serviceFeePrincipalMinor: String(amountCents),
serviceFeeFlow: 'personal_auto_top_up',
}
: {
type: 'org-auto-topup',
organizationId: entity.organization.id,
traceId,
amountCents: String(amountCents),
serviceFeePrincipalMinor: String(amountCents),
serviceFeeFlow: 'organization_auto_top_up',
};

const invoice = await client.invoices.create({
customer: stripe_customer_id,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/credits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ jest.mock('@/lib/firstTopupBonus', () => ({
processFirstTopupBonus: jest.fn(),
}));

jest.mock('@/lib/email', () => ({
sendCreditsTopUpEmail: jest.fn(async () => ({ sent: true })),
}));

describe('processTopUp', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
41 changes: 38 additions & 3 deletions apps/web/src/lib/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ type ProcessTopUpOptions = {
/** If true, this is a native auto top-up (not Orb) */
isAutoTopUp?: boolean;

/** Observed service fee in minor units. Omitted or 0 hides the email fee row. */
serviceFeeCents?: number;

/** Gross amount paid in minor units, including fee and tax when present. */
grossPaidCents?: number;

/** Credits granted in minor units. Defaults to the trusted principal. */
creditsCents?: number;

/**
* Optional transaction handle.
*
Expand Down Expand Up @@ -66,6 +75,9 @@ export async function processTopUp(
creditDescription: creditDescriptionOverride,
creditTransactionId: creditTransactionIdOverride,
skipPostTopUpFreeStuff = false,
serviceFeeCents = 0,
grossPaidCents = amountInCents,
creditsCents = amountInCents,
} = options;

const creditDescription =
Expand Down Expand Up @@ -117,6 +129,9 @@ export async function processTopUp(
await recoverTopUpConfirmationEmailIfMissing({
user,
amountInCents,
serviceFeeCents,
grossPaidCents,
creditsCents,
stripeChargeOrInvoiceId: config.stripe_payment_id,
isAutoTopUp,
});
Expand Down Expand Up @@ -178,6 +193,9 @@ export async function processTopUp(
await maybeSendTopUpConfirmationEmail({
user,
amountInCents,
serviceFeeCents,
grossPaidCents,
creditsCents,
stripeChargeOrInvoiceId: config.stripe_payment_id,
isAutoTopUp,
});
Expand Down Expand Up @@ -245,11 +263,23 @@ async function runPostTopUpBestEffortStep(params: {
async function maybeSendTopUpConfirmationEmail(params: {
user: User;
amountInCents: number;
serviceFeeCents?: number;
grossPaidCents?: number;
creditsCents?: number;
stripeChargeOrInvoiceId: string;
isAutoTopUp: boolean;
purchaseDate?: Date;
}): Promise<void> {
const { user, amountInCents, stripeChargeOrInvoiceId, isAutoTopUp, purchaseDate } = params;
const {
user,
amountInCents,
serviceFeeCents = 0,
grossPaidCents = amountInCents,
creditsCents = amountInCents,
stripeChargeOrInvoiceId,
isAutoTopUp,
purchaseDate,
} = params;
try {
const insertResult = await db
.insert(transactional_email_log)
Expand All @@ -269,8 +299,10 @@ async function maybeSendTopUpConfirmationEmail(params: {
const sendResult = await sendCreditsTopUpEmail({
to: user.google_user_email,
variant: isAutoTopUp ? 'auto' : 'manual',
amountCents: amountInCents,
creditsCents: amountInCents,
principalCents: amountInCents,
serviceFeeCents,
grossPaidCents,
creditsCents,
purchaseDate: purchaseDate ?? new Date(),
receiptUrl,
});
Expand Down Expand Up @@ -306,6 +338,9 @@ async function maybeSendTopUpConfirmationEmail(params: {
async function recoverTopUpConfirmationEmailIfMissing(params: {
user: User;
amountInCents: number;
serviceFeeCents?: number;
grossPaidCents?: number;
creditsCents?: number;
stripeChargeOrInvoiceId: string;
isAutoTopUp: boolean;
}): Promise<void> {
Expand Down
53 changes: 50 additions & 3 deletions apps/web/src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,9 @@ export type CreditsTopUpVariant = keyof typeof CREDITS_TOPUP_COPY;

type BaseSendCreditsTopUpEmailProps = {
to: string;
amountCents: number;
principalCents: number;
serviceFeeCents: number;
grossPaidCents: number;
creditsCents: number;
purchaseDate: Date;
receiptUrl?: string | null;
Expand Down Expand Up @@ -567,6 +569,42 @@ function formatUsd(cents: number): string {
return (cents / 100).toFixed(2);
}

const TOP_UP_INFO_ROW_STYLE =
"margin: 0 0 8px; font-size: 13px; line-height: 20px; color: #555; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";

export function buildCreditsTopUpInfoRow(label: string, value: string): string {
return `<p style="${TOP_UP_INFO_ROW_STYLE}"><strong style="color: #1a1a1a">${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
}

// 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.
Expand All @@ -592,15 +630,24 @@ 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',
subjectOverride: copy.subject,
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),
Expand Down
Loading
Loading