diff --git a/.plans/service-fees/GOAL.md b/.plans/service-fees/GOAL.md new file mode 100644 index 0000000000..0dcf0bdaf9 --- /dev/null +++ b/.plans/service-fees/GOAL.md @@ -0,0 +1,356 @@ +# Stripe service fees + +## Status + +Agreed product goal. Created 2026-08-06 from product decisions and codebase analysis. Implementation is being delivered as a stacked PR series. + +This file is the product requirements source. `SPEC.md` defines the technical design, `VALIDATION.md` defines the end-to-end proof, and `docs/adr/0004-stripe-service-fee-assessment.md` records the architectural decisions and invariants. + +## Goal + +Charge a 5% service fee on eligible Stripe-funded credit purchases for billing objects created at or after `2026-11-01T00:00:00Z`. + +The fee is an additional charge. It does not increase or reduce the credits or Kilo Pass entitlement received. Stripe must show a positive fee as a separate line item named `Service fee (5%)`. + +The change must support organization-specific historical exemptions, retain an auditable fee decision for each commercial billing event, keep product and fee revenue separate, and avoid blocking an underlying payment if fee processing fails. + +## Definitions + +- **Eligible product subtotal**: The aggregate net positive amount for fee-bearing credit products after eligible discounts and proration credits, before tax. It excludes seats, direct KiloClaw charges, the service fee itself, taxes, and unrelated invoice adjustments. +- **Service fee assessment**: The durable decision for one commercial billing event. It records the calculation inputs, cutoff and exemption decisions, expected and charged fee, outcome, settlement, refunds, and related Stripe identities. +- **Commercial billing event**: One customer purchase or subscription invoice, even when Stripe represents it with several objects such as a Checkout Session, Invoice, PaymentIntent, and Charge. +- **Organization exemption**: An internal, exact-organization exception that suppresses service fees on eligible purchases billed to that organization's Stripe customer. +- **Missed fee**: A positive fee that should have been charged but was omitted because fee processing failed open. + +## Eligible transactions + +The service fee applies to: + +- Personal credit top-ups +- Organization credit top-ups +- The initial payment when enabling personal auto-top-up +- The initial payment when enabling organization auto-top-up +- Subsequent personal and organization automatic top-ups +- All Stripe-managed Personal Kilo Pass charges +- Self-service Kilo Pass for Organizations charges + +Kilo Pass coverage includes: + +- Initial purchases +- Renewals +- Upgrades +- Prorations +- Organization capacity increases +- Eligible future invoices for subscriptions created before the activation instant + +All Stripe-managed Personal Kilo Pass subscriptions are treated as self-service. Kilo Pass for Organizations is eligible only when its purchase channel is self-service. + +## Excluded transactions + +The service fee does not apply to: + +- Team or Enterprise seat charges +- Direct KiloClaw subscriptions +- App Store, Google Play, or other store-managed Kilo Pass purchases +- Manual or sales-assisted Kilo Pass for Organizations agreements + +When an invoice contains both seats and Kilo Pass for Organizations, only the net eligible Kilo Pass amount forms the fee base. Seat charges and seat-only discounts must not affect the fee. + +## Activation and timing + +Fee eligibility is fixed when the applicable Stripe billing object is created. + +- A billing object created at or after `2026-11-01T00:00:00Z` is eligible. +- A billing object created before that instant remains fee-free even if payment settles later. +- Interactive purchases use Checkout Session creation time. +- The initial invoice produced by an interactive Checkout inherits the Checkout Session's fee decision. +- Transactions without a Checkout Session use invoice creation time. +- Renewal and proration invoices use their own creation time. + +Existing Kilo Pass subscriptions are not automatically grandfathered. Their eligible invoices created after activation carry the fee unless the exact billed organization is exempt. + +## Fee calculation + +For each commercial billing event: + +1. Sum the net positive eligible product subtotal after discounts and proration credits. +2. Exclude tax, seats, KiloClaw charges, the service fee, and unrelated adjustments. +3. Calculate 5% of the aggregate subtotal. +4. Round once to the nearest cent using round-half-up. +5. Omit the fee line if the rounded fee is zero. + +Discounts allocated to an eligible Kilo Pass charge must reduce its service fee proportionally. A seat-only discount must not reduce the Kilo Pass fee. + +Proportional reduction is what the customer receives, regardless of the mechanism. On interactive Checkout the fee line is itself discountable, so a promotion code reduces product and fee by the same factor and the 5% ratio is preserved arithmetically. On invoices the fee is calculated from already-discounted lines and attached as non-discountable. Both produce the same result. + +A discount reduces the fee base. A prepaid credit balance does not: the fee follows recognized product value, not cash received. A customer who redeems prepaid credit against an eligible purchase still owes the fee on the full product amount. + +Examples: + +| Billing event | Service fee | +|---|---:| +| $100.00 credit top-up | $5.00 | +| $49.00 Kilo Pass | $2.45 | +| $49.00 Kilo Pass with a 20% eligible discount | $1.96 | +| $30.00 positive Kilo Pass proration | $1.50 | +| $720.00 seats plus $49.00 Kilo Pass | $2.45 | +| $0.01 eligible subtotal | $0.00; omit the fee line | + +The customer receives the selected credit principal or normal Kilo Pass entitlement. For example, a `$100.00` top-up charges `$105.00` before tax and grants exactly `$100.00` in credits. + +The service fee follows the eligible product's Stripe tax treatment. The fee base itself is calculated before tax. Finance/tax treatment was confirmed on 2026-08-11: the 5% service-fee line uses the same Stripe `tax_behavior` as the eligible product it accompanies. + +If resolving or applying that treatment fails, fee processing fails open: the payment proceeds without a fee. A fee-domain failure never blocks a customer payment. + +## Stripe presentation + +A positive fee must appear as a separate Stripe line item named: + +`Service fee (5%)` + +Exempt, pre-activation, zero-rounded, and fail-open billing events omit the line. Stripe must not display a zero-value or "waived" fee line. + +Stripe Checkout, hosted invoices, PDFs, and receipts remain the authoritative itemized billing records. + +## Organization exemptions + +A platform admin can grant or revoke an exemption from the organization's Admin UI record. + +Exemption rules: + +- The exemption applies only to purchases owned and billed by the exact organization. +- It does not inherit to a parent or child organization. +- It does not apply to members' personal purchases. +- Granting or revoking an exemption requires a reason. +- A change affects only billing objects created afterward. +- Existing invoices are not changed, refunded, credited, or charged retroactively. +- Exemption status, reason, actor, and history are visible only to platform admins. +- Customer-facing organization APIs and organization audit logs must not expose exemption data. +- Exempt customers simply receive Stripe billing objects without a fee line. + +The exemption feature and Admin UI must be deployed before activation. Platform admins will enter the approved historical exemptions and reasons through the Admin UI. The system must not infer exemptions from fields such as seat requirements, sponsorship, plan, hierarchy, or trial settings. There is no source-controlled organization allowlist. + +## Durable assessments + +The system must persist one idempotent service fee assessment per commercial billing event. Related Stripe objects enrich the same assessment and must not create duplicate assessments or fee revenue. + +An assessment must retain enough information to explain and reconcile the decision, including: + +- Commercial event and flow type +- Applicable user or exact organization owner +- Checkout Session, invoice, PaymentIntent, and charge identifiers when available +- Billing-object creation time and cutoff result +- Eligible subtotal +- Expected fee +- Charged fee +- Exemption decision +- Outcome, including charged, exempt, pre-activation, zero-rounded, or missed +- Settlement state and settled amounts +- Cumulative principal and fee refunds +- Operational failure reason where applicable + +The assessment is a service-fee sidecar, not a product or payment ledger. It is the source for fee reporting, reconciliation, and refund calculations. Product values are retained only as inputs to fee calculation, fee reconciliation, and fee refunds. `credit_transactions` remains the record of credits granted and the source of the existing credit-revenue series. Stripe remains the source for actual charges, invoices, refunds, disputes, and invoice presentation. + +## Failure behavior + +Fee processing fails open. + +If fee calculation, exemption lookup, or fee attachment fails: + +- The underlying purchase, automatic top-up, renewal, or proration proceeds without the fee. +- Credits and entitlements follow their normal settlement rules. +- The assessment records the expected fee and missed outcome. +- The missed fee is never charged retroactively or carried into another billing event. +- Each processing retry sends another notification through the existing Admin Slack notification system. Retry alerts are intentionally not deduplicated. +- Slack delivery failure does not block payment processing. +- Notifications may contain non-sensitive billing identifiers, amounts, flow type, and reason codes. They must not contain tokens, payment credentials, sensitive headers, or other secrets. + +There is no runtime kill switch. Disabling service fees requires a deployment rollback or code change. + +## Credit settlement + +Credit settlement must use the trusted credit principal, not the gross Stripe amount. + +Today, top-up settlement uses `charge.amount` or `invoice.amount_paid`, both of which will include the service fee after this change. Those values must not determine credits granted. A `$100.00 + $5.00` payment must create only `$100.00` of credits. + +Personal Kilo Pass entitlement remains based on its configured tier rules rather than invoice total. Kilo Pass reporting and affiliate calculations must use product amounts that exclude the service fee. + +## Refunds + +The service fee is refundable in proportion to the eligible product principal refunded. + +- A full refund returns the full service fee. +- A partial refund returns the corresponding portion of the service fee. +- Multiple partial refunds use cumulative proportional rounding: calculate the total fee that should have been refunded for cumulative eligible principal refunded, round half-up, then subtract fee already refunded. +- A fully refunded purchase must return exactly the full original fee, without rounding drift. +- Settled refunds reduce collected service-fee revenue. + +This work does not add a general partial-refund Admin UI. Existing Kilo-initiated full-refund paths must include the full gross payment, including the fee. Operators issuing partial refunds directly in Stripe must include the proportional fee according to the operator runbook. Stripe refund events update the durable assessment with observed refund amounts. + +A chargeback reverses the fee in the same way a refund does, because the money leaves the account. A dispute later resolved in Kilo's favor restores it. The disputed fee is tracked separately from refunds because a dispute outcome can move in either direction; the assessment does not track disputed product revenue. + +The system must not automatically issue a second refund when an operator's Stripe partial refund appears not to include a proportional fee because the operator's intended gross-versus-principal amount cannot be inferred safely. + +## Revenue and affiliate reporting + +Service-fee revenue is separate from product revenue. + +- Credits and Kilo Pass product revenue exclude the fee. +- Affiliate-commissionable amounts exclude the fee. +- Gross-payment and fraud telemetry may include the full charge. +- Collected service-fee revenue is recognized only after Stripe confirms payment settlement. +- Unpaid or abandoned billing objects do not count as collected, missed, or exempted revenue. +- Settled refunds and withdrawn chargebacks reduce collected fee revenue. +- Collected fee revenue is measured from the fee Stripe actually settled, not the fee Kilo requested. On discounted interactive purchases these differ. + +The Admin revenue dashboard keeps its existing paid, free, multiplied, and unmultiplied credit-transaction semantics, including their current limitations. Assessment-backed top-ups remain in those series. Separately, settled assessments report: + +- Collected service-fee revenue +- Expected but missed fee value +- Exempted fee value +- Disputed fee value +- Charged, missed, and exempt assessment counts + +The fee series does not make the dashboard a complete Stripe product-revenue report and must not be added to credit revenue to claim authoritative gross revenue. Kilo Pass product revenue remains outside the current query and requires separate product and accounting work. Unpaid and abandoned assessments remain available for audit but do not count as fee revenue or leakage. + +## Customer communications + +### Existing top-up emails + +Existing personal, organization, and automatic top-up confirmation emails must itemize the following when a positive fee was charged: + +- Credits added +- `Service fee (5%)` +- Total paid + +When no fee was charged, the email omits the fee row and does not reveal whether the reason was exemption, pre-activation timing, zero rounding, or a fail-open error. + +### Kilo Pass emails + +This change does not introduce new Personal Kilo Pass or Kilo Pass for Organizations payment or renewal emails. If such emails are added later and state an eligible payment amount, they should itemize product principal, service fee, and gross payment. + +### Kilo UI and billing history + +Kilo's pre-purchase price controls and auto-top-up settings remain unchanged. Stripe Checkout discloses the fee before an interactive payment. + +Kilo billing-history tables continue to show the gross invoice amount without a fee breakdown. Customers can use the linked Stripe invoice or PDF for authoritative line-item details. + +## Operational constraint: product-restricted coupons + +A Stripe coupon restricted to specific products (`applies_to`) would discount an eligible product without discounting the fee line, making the customer pay more than the published 5%. Because the customer enters promotion codes on Stripe's hosted page, this cannot be detected before the fact, and a dashboard-created coupon requires no deployment. + +Therefore: never create a coupon restricted to a fee-bearing product. This is an operational rule with a scheduled detection check and a settlement-time effective-rate alert, not something code can prevent. + +## Out of scope + +- Service fees on seats or direct KiloClaw subscriptions +- Store-managed Kilo Pass fees +- Fees on manual or sales-assisted agreements +- Personal-user fee exemptions +- Parent, child, or member inheritance of organization exemptions +- A customer-facing exemption indicator +- A general partial-refund Admin UI +- New Kilo Pass payment or renewal emails +- A runtime fee kill switch +- Retroactive fee collection +- Changes to Kilo pre-purchase price presentation or billing-history itemization + +## Codebase impact identified during analysis + +This is a cross-cutting billing change, not a Checkout-only change. + +### Stripe creation and lifecycle paths + +Expected areas include: + +- `apps/web/src/lib/stripe/index.ts` + - Manual top-up Checkout + - Personal auto-top-up setup Checkout + - Credit settlement and webhook dispatch +- `apps/web/src/lib/organizations/organization-auto-top-up.ts` + - Organization auto-top-up setup Checkout +- `apps/web/src/lib/autoTopUp.ts` + - Subsequent off-session automatic invoices +- `apps/web/src/routers/kilo-pass-router.ts` + - Personal Kilo Pass Checkout and subscription changes +- `apps/web/src/lib/kilo-pass-org/stripe-adapter.ts` + - Organization Kilo Pass add-on invoices and mixed seat invoices +- `apps/web/src/routers/organizations/organization-subscription-router.ts` + - Seat and Kilo Pass capacity changes and schedules + +Existing recurring subscriptions need invoice-time fee handling. Updating only new Checkout Sessions would miss renewals for subscriptions created before activation. + +### Persistence and Admin UI + +Expected areas include: + +- `packages/db/src/schema.ts` and generated migrations +- Internal exemption state and history +- Service fee assessments and Stripe identity links +- `apps/web/src/routers/organizations/organization-admin-router.ts` +- `apps/web/src/app/admin/components/OrganizationAdmin/` + +Exemption history must not use a customer-visible organization audit stream. + +### Reporting, alerts, and email + +Expected areas include: + +- `apps/web/src/lib/revenueKpi.ts` +- Admin revenue dashboard components +- `apps/web/src/lib/slack/admin-notifications.ts` +- Existing top-up email sending contracts and template + +### Classification risks + +A service-fee line must never be mistaken for: + +- A Kilo Pass for Organizations subscription add-on +- A seat item +- A direct KiloClaw item +- Credit principal + +This is especially important in organization Kilo Pass code that currently treats a non-seat subscription item as the Kilo Pass item. + +## Acceptance criteria + +The implementation plan must provide verification for at least these cases: + +1. Exact activation-boundary behavior for Checkout-created and invoice-created transactions. +2. Correct fees for manual top-ups, auto-top-up setup, subsequent auto-top-ups, Personal Kilo Pass, and self-service Kilo Pass for Organizations. +3. No fee for seats, direct KiloClaw, store purchases, or manual organization agreements. +4. Mixed seat and Kilo Pass invoices charge only on the net eligible Kilo Pass portion. +5. Eligible product discounts proportionally reduce the fee; seat-only discounts do not. +6. Aggregate half-up rounding and omission of zero-cent fee lines. +7. Credit grants use principal rather than gross payment. +8. Existing subscriptions receive fees on post-activation invoices. +9. Exact-organization exemption behavior and Admin-only grant, revoke, reason, and history access. +10. One assessment across related Checkout, invoice, PaymentIntent, and charge objects. +11. Idempotent settlement and reporting across webhook retries. +12. Fail-open payment behavior, durable missed-fee records, and a repeated Admin Slack alert on every retry. +13. Existing credit-revenue semantics remain unchanged, while collected, missed, exempted, and disputed fee values use settled assessments only. +14. Affiliate amounts exclude fees while gross fraud telemetry can include them. +15. Full and cumulative partial-refund calculations return no more or less than the original fee. +16. Fee-bearing top-up emails itemize Credits added, `Service fee (5%)`, and Total paid; fee-free emails omit the fee row. +17. Billing-history summaries remain unchanged and Stripe invoices contain the authoritative fee line. +18. Fee lines do not interfere with existing seat, Kilo Pass, KiloClaw, invoice, or refund classifiers. + +## Release prerequisites + +- Finance/tax confirmed on 2026-08-11 that the fee receives the same Stripe tax treatment as the eligible product. +- The schema, Admin exemption controls, assessment pipeline, reporting, and alerting are deployed before activation. +- Platform admins enter and verify all approved historical organization exemptions before `2026-11-01T00:00:00Z`. +- Operators have a documented procedure for proportional partial refunds in Stripe and for responding to missed-fee Slack alerts. +- Rollout verification confirms that existing Personal Kilo Pass and self-service Kilo Pass for Organizations invoices created after activation receive the correct fee. + +## Main implementation risks + +1. Attaching recurring fees to invoices for existing subscriptions. +2. Preserving an exact 5% net fee after Stripe discounts and mixed-invoice allocation. +3. Keeping seat charges and seat-only discounts out of the fee base. +4. Separating gross payment from credit principal everywhere credits are granted. +5. Preventing fee lines from being misclassified as products. +6. Updating one durable assessment across asynchronous Stripe objects and retries. +7. Reporting fail-open leakage without counting unpaid or duplicate events. +8. Reconciling cumulative partial refunds without rounding drift. +9. Validating the confirmed mirrored tax treatment in Stripe test mode before release. diff --git a/.plans/service-fees/SPEC.md b/.plans/service-fees/SPEC.md new file mode 100644 index 0000000000..f8cae87594 --- /dev/null +++ b/.plans/service-fees/SPEC.md @@ -0,0 +1,1253 @@ +# Stripe service fee technical integration specification + +## Status and authority + +Implementation specification for `.plans/service-fees/GOAL.md`, prepared from the repository state on 2026-08-06. + +`GOAL.md` is authoritative for product behavior. This document fixes the technical design and execution order. If implementation reveals that a step cannot preserve the goal, stop and revise the spec rather than weakening the behavior in code. + +Before changing a scoped area, read its nearest `AGENTS.md`. Database changes must follow `packages/db/AGENTS.md` and the `database-migrations` skill. React changes must follow `apps/web/AGENTS.md` and the `kilo-design-cloud` skill. Test work must follow the `testing-principles` skill. + +## Decision record + +`docs/adr/0004-stripe-service-fee-assessment.md` is authoritative for architectural decisions and invariants. The table below is a working summary. Update the ADR and this specification when a decision changes. + +Resolved during spec review on 2026-08-08. Each entry supersedes earlier drafts of this document. + +| ID | Decision | Rationale | +|---|---|---| +| D1 | The fee base follows **recognized product value**. Discounts reduce it; prepaid credit-balance consumption does not. | Preserves the invariant that collected fee equals 5% of product revenue, which is what makes the assessment table reconcilable. Kilo does not use Stripe Billing credit grants today, so this is a forward-looking guard. | +| D2 | **Keep hosted promotion-code entry** for Personal Kilo Pass. Compute the fee from list price and accept that the fee line is discounted along with the product. | Proportional allocation makes the result arithmetically exact for unrestricted coupons, so `GOAL.md`'s proportional-reduction requirement is met without server-side discount knowledge. Avoids private-preview dependencies and avoids rewriting a live payments path. Cost: product-restricted coupons must be prevented operationally. | +| D3 | Chargebacks **reverse the fee** on `charge.dispute.funds_withdrawn`, tracked in the dedicated non-monotonic fee-dispute column. | The money left the account, so leaving it in collected fee revenue overstates the one number this subsystem exists to produce. Keeping the fee consequence separate preserves refund monotonicity when a won dispute restores funds. | +| D4 | On any failure to resolve or apply the confirmed mirrored tax treatment, **fail open in production** exactly as in non-production. | Applies the document's existing fail-open principle consistently. Not collecting a fee for that event is strictly better than mis-taxing customers or blocking payments. | + +Two defects were also corrected without requiring a decision: the Stripe wire API version was never pinned, and automatic top-up invoices had two competing fee-attachment paths. Both are addressed below. + +## Tax treatment + +Finance/tax treatment was confirmed on 2026-08-11: the 5% service-fee line uses the same Stripe `tax_behavior` as the eligible product it accompanies. Keep tax behavior resolution isolated in `tax.ts`. + +The repository sets no per-request tax parameters today. There is no `automatic_tax`, `tax_behavior`, `default_tax_rates`, or `tax_rates` in production code; only `tax_id_collection` is used (`apps/web/src/lib/stripe/index.ts`, three call sites, and `apps/web/src/routers/kilo-pass-router.ts`, one). Stripe tax behavior is therefore configured at the account and Price level, not by this codebase. That narrows the work to mirroring, not selecting, a treatment: + +- **Top-ups with an explicit amount** build the principal as inline `price_data` with no `tax_behavior` (`apps/web/src/lib/stripe/index.ts:1503-1513`). A fee line built the same way inherits identical treatment by construction. Nothing to decide and no way for the two to diverge. +- **Default top-up** (`price: STRIPE_TOP_UP_PRICE_ID`) and **Kilo Pass** (`getStripePriceIdForKiloPass()`) reference Price objects that may carry a dashboard-set `tax_behavior`. Here the principal is a Price and the fee is inline `price_data`, so they can diverge. `tax.ts` must retrieve the eligible Price, read its `tax_behavior`, and set the same value on the fee line. + +That treatment was confirmed on 2026-08-11 and is recorded in ADR 0004. + +**On any tax-resolution or application failure: fail open, identically to non-production.** If the eligible Price cannot be read, its `tax_behavior` is not explicitly `inclusive` or `exclusive`, or the fee line cannot apply the mirrored behavior, do not construct the fee line. The payment proceeds without a fee, the assessment records `missed` with the normal `fee_application_failed` code, and Slack alerts. Do not fail closed on a payment for a fee-domain reason, add a runtime switch, or deploy a guessed fallback. + +The design uses explicit fee lines rather than a recurring Stripe subscription item. A fee line is classified by metadata and by the durable assessment, never by its display description alone. + +## Fixed constants and vocabulary + +Create `apps/web/src/lib/service-fees/constants.ts` with these code-owned constants: + +```ts +export const SERVICE_FEE_RATE_BASIS_POINTS = 500; +export const SERVICE_FEE_RATE_DENOMINATOR = 10_000; +export const SERVICE_FEE_ACTIVATION_UNIX_SECONDS = 1_793_491_200; // 2026-11-01T00:00:00Z +export const SERVICE_FEE_DESCRIPTION = 'Service fee (5%)'; +export const SERVICE_FEE_METADATA_TYPE = 'kilo-service-fee'; +export const SERVICE_FEE_VERSION = '2026-11-01-v1'; +``` + +Do not add an environment variable, PostHog flag, database setting, or other runtime global switch. Rollback is the emergency off mechanism. + +### Pin the Stripe API version first + +`apps/web/src/lib/stripe-client.ts:13` constructs the client as `new Stripe(stripeSecretKey)` with no `apiVersion`. The string `2025-10-29.clover` is only the SDK's compiled-in default (`node_modules/stripe/cjs/apiVersion.js:5`); it is **not** a pin. Without an explicit `apiVersion`, requests use the Stripe **account** default, which can differ from the installed typings and can be changed from the Stripe dashboard without a deploy. + +Every line-shape assumption in this document — `discount_amounts` versus `pretax_credit_amounts`, `pricing.price_details.price`, `parent.subscription_item_details` — depends on that wire contract. Pin it before any fee work: + +```ts +export const client: Stripe = new Stripe(stripeSecretKey, { apiVersion: '2025-10-29.clover' }); +``` + +This is Phase 1 step 0. Run the existing Stripe suites immediately after pinning and before adding fee behavior, so any pre-existing drift between the account version and the typings surfaces as an isolated change rather than inside the fee diff. + +Use integer minor units throughout the fee domain. The first release supports the current eligible currency, USD. + +For an eligible non-USD commercial event, do not compute a fee: skip fee construction, persist outcome `unsupported_currency` with zero amounts, and alert. Do not record `missed`, because `missed` requires a computed `expected_fee_minor > 0` and no trustworthy expected fee exists for an unsupported currency. Never apply USD assumptions to another currency. + +### Flow values + +Use a closed TypeScript value set and a database check constraint for: + +- `personal_top_up` +- `organization_top_up` +- `personal_auto_top_up_setup` +- `organization_auto_top_up_setup` +- `personal_auto_top_up` +- `organization_auto_top_up` +- `personal_kilo_pass` +- `organization_kilo_pass` + +### Outcome values + +Use a closed value set and `enumCheck()` for: + +- `pending` +- `charged` +- `exempt` +- `pre_activation` +- `zero_rounded` +- `unsupported_currency` +- `missed` + +This is the only fee-decision state column. There is no separate `application_state`. + +The outcome is the fee decision, not payment state. Settlement and refunds have separate columns. + +## Architecture + +Create a server-only service-fee module under `apps/web/src/lib/service-fees/`: + +| File | Responsibility | +|---|---| +| `constants.ts` | Activation, rate, label, metadata keys, version | +| `types.ts` | Closed flow/outcome/owner types and result contracts | +| `calculation.ts` | Pure integer fee and refund calculations | +| `stripe-lines.ts` | Stripe line pagination, classification, net eligible subtotal, tax input extraction | +| `assessments.ts` | Assessment create/upsert/link/settle/refund persistence | +| `checkout.ts` | Checkout assessment preparation and positive fee line construction | +| `invoice-created.ts` | Draft invoice assessment and fee-line attachment | +| `settlement.ts` | Resolve assessment from invoice, PaymentIntent, or charge and mark settlement | +| `refunds.ts` | Observe cumulative Stripe refunds and update assessment state | +| `alerts.ts` | Best-effort Admin Slack notification for missed fees | +| `organization-exemptions.ts` | Exact-organization exemption read and mutation support | + +Keep pure arithmetic and line classification free of database and Stripe clients. Pass Stripe and database dependencies into integration helpers where practical so tests do not replace the behavior under test with mocks. + +### Processing model + +There are three fee-attachment paths: + +1. **Interactive Checkout, fee computed before creation**: top-ups and Personal Kilo Pass. Compute the fee from the known list principal before `checkout.sessions.create()`, add a one-time fee line, then persist the returned Checkout Session ID. The returned `created` timestamp is authoritative for the activation boundary. +2. **Kilo-owned invoice billing**: automatic top-ups. Kilo creates the invoice with `auto_advance: false` and pays it directly, so the fee item is attached before `invoices.pay()` and the `invoice.created` webhook must not touch it. See Ownership of invoice fee attachment. +3. **Stripe-owned invoice billing**: renewals, prorations, and capacity changes. Handle `invoice.created` while the invoice is still draft, compute from all invoice lines, attach one non-discountable fee invoice item, persist the assessment, and acknowledge the webhook even when fee work fails. + +Settlement uses the assessment prepared by one of these paths. Settlement never recalculates the fee from the gross paid amount. + +### Discountable initial fee lines + +Subscription-mode Checkout supports neither `add_invoice_items` nor a `discountable` flag on line items (verified against the installed SDK: `SessionCreateParams` has no `add_invoice_items`; `SessionCreateParams.LineItem` has no `discountable`). A one-time fee line on a Checkout Session is therefore **always discountable**, and a promotion code the customer enters on Stripe's hosted page will discount it along with the product. + +**This is accepted behavior, not a defect.** For a coupon that discounts the whole invoice, it is arithmetically exact. The fee line sits inside the discounted subtotal, so both lines scale by the same factor and the ratio survives: + +`0.05 x list x (1 - d) === 0.05 x (list x (1 - d))` + +Worked example, $49.00 Kilo Pass with a 20% promotion code and the fee computed from list price: + +| Line | Amount | +|---|---:| +| Kilo Pass | $49.00 | +| Service fee (5%) | $2.45 | +| Subtotal | $51.45 | +| 20% off | -$10.29 | +| **Total** | **$41.16** | + +Product recognized $39.20; fee collected $1.96; and 5% of $39.20 is $1.96. `GOAL.md`'s requirement that eligible discounts proportionally reduce the fee is satisfied without the server knowing the discount in advance. A fixed-amount coupon allocated proportionally behaves the same way. + +Two consequences follow, and both are load-bearing. + +**`expected_fee_minor` is provisional on this path.** It is computed from list price and will exceed the amount actually collected whenever a coupon applies. `charged_fee_minor` must be read from the **settled invoice fee line**, never from what was sent to Stripe. The equality constraint between expected and charged fees does not apply to Checkout-created assessments; see the amount and state checks. + +**Product-restricted coupons overcharge and must be prevented operationally.** `Coupon.applies_to.products` limits a coupon to specific products. Such a coupon discounts the Kilo Pass line and leaves the fee line untouched: product $49.00 becomes $39.20, the fee stays $2.45, and the customer pays an effective 6.25% against a published 5%. The restriction cannot be detected before the customer enters the code, and a dashboard-created coupon needs no deploy, so no test in this repository can catch it. Required mitigations: + +- Operator runbook entry: never create a coupon with a non-null `applies_to` that covers a fee-bearing product. +- A scheduled read-only check that lists coupons whose `applies_to.products` intersects Kilo Pass or top-up products and alerts Admin Slack. +- An effective-rate assertion at settlement: when `charged_fee_minor` deviates from 5% of `settled_product_minor` by more than one cent, record `service_fee_rate_deviation` and alert. This is the restricted-coupon signature. + +Current exposure is low but unverified from this repository. The only coupon in code or `ENVIRONMENT.md` is `STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID` (`apps/web/src/lib/config.server.ts:417`), and KiloClaw is fee-exempt. The two-month Kilo Pass promotion is an internal credits mechanism (`apps/web/src/lib/kilo-pass/usage-triggered-bonus.ts`), not a Stripe coupon. Customer-redeemable promotion codes live in the Stripe dashboard and are invisible here, so treat low exposure as a snapshot, not a guarantee. + +A 100% coupon zeroes both lines. That is the correct economic outcome and must be recorded as outcome `charged` with `charged_fee_minor = 0`, not as `missed`. + +Renewal and proration invoices are unaffected: they compute from actual discounted draft lines and attach the fee with `discountable: false`, so they are exact by construction. The resulting asymmetry is intentional — initial invoices self-correct through proportional allocation, later invoices are exact because the discount is already known. + +### Activation-boundary replacement + +Exemption drift needs no special handling: resolving the latest exemption-log row at or before `eligibility_created_at` is already correct for any delivery delay. + +Only the activation instant can genuinely straddle a single request. For a Checkout created within one minute of `SERVICE_FEE_ACTIVATION_UNIX_SECONDS`, compare the prepared decision against the returned `session.created`; if they disagree, expire the session and create one replacement with the correct decision. If the replacement also disagrees, fail open with a fee-free session and a missed assessment. Outside that window, do not add an expire/replace round trip to every purchase. + +## Database design + +Add the following tables to `packages/db/src/schema.ts`. Follow `packages/db/AGENTS.md`. + +Use the repository's existing idioms rather than inventing new ones: + +- Primary keys: use `assessment_key` directly for assessments. Reuse the shared `idPrimaryKeyColumn` (`packages/db/src/schema.ts:2688`) for exemption-log row IDs. Do not use `defaultRandom()`. +- Timestamps: `timestamp({ withTimezone: true, mode: 'string' })`, with `.defaultNow().notNull()`, and an `$onUpdateFn` returning `now()` for `updated_at`. + +```ts +// packages/db/src/schema.ts:2688, reuse for exemption-log IDs rather than redefine +const idPrimaryKeyColumn = uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(); +``` +- Closed value sets: use the existing `enumCheck()` helper (`packages/db/src/schema.ts:180`) with a TypeScript enum and `$type<...>()`, as `stripe_dispute_cases` does. Do not hand-roll `check()` for enum columns. +- Amounts: `integer()` minor units. + +Generate the migration with `pnpm drizzle generate`. Do not hand-write or edit generated DDL, snapshots, or journal entries. Prefer one generated migration for this branch. + +These tables store no user or account PII — only actor foreign keys — so `softDeleteUser` needs no change. State that conclusion in the PR rather than leaving it open. + +### `organization_service_fee_exemptions` + +Append-only internal exemption log. The newest row is current state; a false row records revocation: + +| Column | Type | Rules | +|---|---|---| +| `id` | UUID | Primary key | +| `organization_id` | UUID | Not null; FK to `organizations.id`; `onDelete: restrict` | +| `is_exempt` | boolean | Not null | +| `reason` | text | Not null; trimmed non-empty | +| `changed_by_kilo_user_id` | text nullable | FK to `kilocode_users.id`, `onDelete: set null` | +| `created_at` | timestamptz string | Not null, default now | + +Index `(organization_id, created_at desc)`. Resolve current state and historical state with the same deterministic order: `created_at desc, id desc`. Keep the organization-scoped advisory lock around each append. Assessments point directly to the exact exemption row used for their decision. + +This table is not `organization_audit_logs`; it must be returned only by `adminProcedure` endpoints. Do not add these fields to `organizations.settings` or expose them through ordinary organization contracts. When an admin is soft-deleted, nulling the actor FK is sufficient. Do not store actor email or name. + +### `stripe_service_fee_assessments` + +One row per commercial billing event: + +| Column | Type | Rules | +|---|---|---| +| `assessment_key` | text | Primary key; not null and immutable | +| `version` | text | Not null; initial value `2026-11-01-v1` | +| `flow` | text | Not null; checked against flow values | +| `outcome` | text | Not null; `enumCheck` against outcome values | +| `currency` | text | Not null, lowercase ISO code | +| `kilo_user_id` | text nullable | FK to `kilocode_users.id`, `onDelete: set null` | +| `organization_id` | UUID nullable | FK to `organizations.id`, `onDelete: restrict` | +| `stripe_customer_id` | text nullable | Indexed | +| `stripe_checkout_session_id` | text nullable | Partial unique index | +| `stripe_invoice_id` | text nullable | Partial unique index | +| `stripe_payment_intent_id` | text nullable | Partial unique index | +| `stripe_charge_id` | text nullable | Partial unique index | +| `stripe_fee_price_id` | text nullable | Checkout-generated fee Price identity | +| `stripe_checkout_fee_line_item_id` | text nullable | Partial unique index | +| `stripe_invoice_fee_line_item_id` | text nullable | Partial unique index | +| `eligibility_created_at` | timestamptz string | Not null; Checkout or invoice creation instant used for cutoff | +| `eligible_subtotal_minor` | integer | Not null, non-negative | +| `expected_fee_minor` | integer | Not null, non-negative; provisional for Checkout flows | +| `charged_fee_minor` | integer | Not null, default 0, non-negative; from the settled fee line | +| `gross_paid_minor` | integer | Not null, default 0, non-negative | +| `settled_product_minor` | integer | Not null, default 0, non-negative | +| `settled_at` | timestamptz string nullable | Set only after positive/zero successful settlement | +| `refunded_product_minor` | integer | Not null, default 0, non-negative; monotonic | +| `refunded_fee_minor` | integer | Not null, default 0, non-negative; monotonic | +| `refunded_gross_minor` | integer | Not null, default 0, non-negative; monotonic | +| `disputed_fee_minor` | integer | Not null, default 0, non-negative; not monotonic | +| `exemption_id` | UUID nullable | FK to the exact exemption log row, `onDelete: restrict` | +| `failure_code` | text nullable | Stable internal reason code, no secret or raw provider payload | +| `metadata` | JSONB | Not null, default `{}`; only non-sensitive reconciliation facts | +| `created_at` | timestamptz string | Not null, default now | +| `updated_at` | timestamptz string | Not null, default now, update hook | + +Owner check: + +- Personal flows require `kilo_user_id` and forbid `organization_id`. +- Organization flows require `organization_id`; `kilo_user_id` may record the initiating user. + +`outcome` is the single decision-state column. Both the earlier `application_state` design and the separate `eligibility` column were removed because they were derivable from `outcome`, expected amount, and attachment success. Outcome values: + +| Outcome | Meaning | +|---|---| +| `pending` | Decision made, provider attachment not yet confirmed | +| `charged` | A fee line was accepted by Stripe | +| `exempt` | Suppressed by an exact-organization exemption | +| `pre_activation` | Billing object created before the activation instant | +| `zero_rounded` | Eligible, but 5% rounded to zero cents | +| `unsupported_currency` | Eligible event in a currency this release does not support | +| `missed` | Positive fee should have been charged; fee processing failed open | + +Amount and state checks: + +- `refunded_fee_minor <= charged_fee_minor` and `refunded_product_minor <= settled_product_minor`. Both refund columns are monotonic. +- `disputed_fee_minor <= charged_fee_minor`. It is **not** monotonic; a dispute resolved in Kilo's favor clears it. +- `outcome = pending` requires `charged_fee_minor = 0` and no settlement. +- `outcome = charged` requires a settled or attached fee line. `charged_fee_minor` may be zero only when `settled_product_minor` is also zero, which is the fully discounted purchase case. +- `outcome = missed` requires `expected_fee_minor > 0`, `charged_fee_minor = 0`, and a non-empty `failure_code`. +- `outcome = zero_rounded` requires `expected_fee_minor = 0`. +- `outcome IN (exempt, pre_activation, zero_rounded, unsupported_currency)` requires `charged_fee_minor = 0`. +- `outcome = exempt` requires `exemption_id`; every other outcome forbids it. +- **Do not constrain `charged_fee_minor <= expected_fee_minor`, and do not require equality.** For Checkout flows `expected_fee_minor` is computed from list price and the collected fee is typically lower because a promotion code discounted the fee line proportionally. Equality holds only for invoice-attached fees. Enforce the relationship through the settlement-time effective-rate assertion instead of a database check. +- Guarded update predicates must reject settlement of a `pending` assessment; it must first reach a terminal outcome. Admin monitoring reports stale pending rows. No cron later attaches their fee. + +Assessment keys: + +- Checkout: generate `checkout:` before the Stripe call and put the UUID in Checkout and PaymentIntent/subscription metadata. It becomes the assessment's `assessment_key`. +- Invoice-only: `invoice:`. +- The initial subscription invoice from Checkout must bind to the existing Checkout assessment via propagated assessment metadata, not create an `invoice:*` assessment. + +Do not store email, billing address, tax ID, card data, or webhook bodies in the assessment. + +### Migration checks + +After generation: + +1. Confirm the migration creates only the two intended tables, checks, FKs, and indexes. +2. Confirm there is no destructive DDL. +3. Run `pnpm drizzle:verify-bootstrap` if the branch migration changes bootstrap behavior. +4. Run `packages/db/src/schema.test.ts` and package typecheck. + +## Pure calculation contracts + +Implement and unit-test these functions in `calculation.ts`. + +### Fee rounding + +```ts +calculateServiceFeeMinor(eligibleSubtotalMinor: number): number +``` + +Requirements: + +- Input is a non-negative safe integer. +- Return `floor((eligibleSubtotalMinor * 500 + 5_000) / 10_000)` using `bigint` intermediates, then convert back only after a safe-integer check. +- This is round-half-up for a positive 5% amount. +- Calculate once on the aggregate eligible subtotal, not once per line. +- Return zero for a zero or sub-ten-cent subtotal where 5% rounds below one cent. + +Do not use binary floating-point multiplication by `0.05` for persisted amounts. + +### Net line amount + +```ts +getNetPretaxLineAmountMinor(line: Stripe.InvoiceLineItem): number +``` + +Use the Stripe line's signed pretax amount after discounts only. On the pinned Stripe API version, `discount_amounts` are also represented as `pretax_credit_amounts` entries of type `discount`; subtract each economic adjustment exactly once: + +```text +line.amount +- sum(line.pretax_credit_amounts[] where type === 'discount' -> amount) +``` + +If a fixture/provider shape has `discount_amounts` but no matching discount pretax-credit entries, subtract the `discount_amounts` fallback. Never subtract both representations of the same discount. + +**The `type === 'discount'` filter is required, not an optimization.** `PretaxCreditAmount.type` is `'credit_balance_transaction' | 'discount'` (`node_modules/stripe/types/InvoiceLineItems.d.ts:168`). The fee tracks **recognized product value**, not cash received, so prepaid balance consumption must not shrink the fee base: + +- A **discount** reduces the price, so it reduces the fee base. +- A **credit balance transaction** (Stripe Billing credit grant) is a payment method, not a price reduction. A customer redeeming $49 of prepaid credit against a $49 Kilo Pass still generates $49 of product revenue and owes a $2.45 fee. +- Customer credit balance from credit notes or overpayment does not appear in this field at all; it reduces `amount_due` through `invoice.starting_balance`. It is recorded only as reduced cash in `gross_paid_minor`. + +This preserves the invariant that collected fee equals 5% of recognized product revenue, which is what makes the assessment table reconcilable and what the expected-versus-charged checks assume. `GOAL.md` names "eligible discounts and proration credits" as the two reducing adjustments; a credit grant is neither. + +Kilo does not use Stripe Billing credit grants today (no `creditGrants` or `credit_balance` usage in `apps/web/src`), so this is a forward-looking guard. Revisit the policy if credit grants are adopted, and add a fixture with a `credit_balance_transaction` entry so a future adoption cannot silently change the fee base. + +Clamp only the final event subtotal to zero. Preserve negative eligible lines while summing so proration credits offset positive eligible lines. Do not include tax fields. Assert safe integers and matching currency. Add fixtures for the actual pinned API version so a Stripe version change cannot silently double-subtract discounts. + +The implementation must retrieve all invoice lines through `stripe.invoices.listLineItems()` when `invoice.lines.has_more` is true. Never calculate from the first embedded page only. + +### Cumulative refund rounding + +```ts +calculateCumulativeFeeRefundMinor({ + originalProductMinor, + originalFeeMinor, + cumulativeProductRefundMinor, +}): number +``` + +Requirements: + +- Return zero when no product principal has been refunded. +- Return zero when `originalProductMinor` is zero. Guard the division explicitly; a fully discounted purchase settles at zero product and zero fee, so this input is reachable. +- Return the complete original fee when cumulative principal reaches original principal. +- Otherwise return round-half-up of `originalFeeMinor * cumulativeProductRefundMinor / originalProductMinor` using `bigint` intermediates. +- The incremental fee refund is cumulative target minus `refunded_fee_minor` already recorded. +- Never return a negative incremental refund or more than the remaining fee. + +## Stripe metadata contract + +Use namespaced string metadata. Keep each value under Stripe limits. + +### Fee line metadata + +Every positive fee line must contain: + +```ts +{ + type: 'kilo-service-fee', + serviceFeeVersion: '2026-11-01-v1', + serviceFeeAssessmentKey: assessmentKey, + serviceFeeRateBasisPoints: '500', +} +``` + +### Commercial object metadata + +Add these facts to the object available for the flow: + +- `serviceFeeAssessmentKey` +- `serviceFeeVersion` +- `serviceFeeFlow` +- `serviceFeePrincipalMinor` for top-ups +- `serviceFeeOrganizationId` for organization flows + +Preserve existing metadata. Do not overwrite Kilo Pass, KiloClaw, seat, affiliate, or scheduled-change metadata. + +### Classifier rules + +Add shared helpers: + +- `isServiceFeeMetadata(metadata)` +- `isServiceFeeInvoiceLine(line)` +- `isServiceFeeCheckoutLine(line)` where needed +- `isKnownKiloPassInvoiceLine(line)` +- `isSeatInvoiceLine(line, subscription?)` +- `isKiloClawInvoiceLine(line)` + +A line is a service fee only when the namespaced metadata marker and version are present, or when its line/Price identity is linked to an assessment during reconciliation. The description alone is insufficient. + +Checkout does not provide line-item metadata directly on the pinned API. Put the marker in inline `price_data.product_data.metadata`; after Session creation, list Checkout lines with `data.price.product` expanded, identify the marked line, and persist its line and Price IDs. Reconcile the generated invoice line by the persisted Price ID, then store its invoice-line ID. Invoice items created directly use invoice-item metadata. + +Update every "first non-seat item" assumption in `apps/web/src/lib/kilo-pass-org/stripe-adapter.ts`. Resolve the organization Kilo Pass item in this order: + +1. Persisted `provider_seat_add_on_item_id`. +2. Known Kilo Pass price ID on a non-seat subscription item. +3. Metadata-backed Kilo Pass item when unbound. + +A service-fee invoice item is not a subscription item, but invoice scanning must still explicitly exclude it. Keep seat and Kilo Pass subscription-item resolution independent of invoice line order. + +Update invoice classifiers so: + +- Kilo Pass remains recognized by known recurring price or subscription metadata. +- KiloClaw remains recognized only by its known prices/metadata. +- Seats remain recognized by seat product/known prices. +- A fee-only line can never make an invoice look like one of those products. + +## Assessment API + +Implement these server-only contracts in `assessments.ts` and `settlement.ts`. + +### Prepare a decision + +```ts +type PrepareAssessmentInput = { + assessmentKey: string; + flow: ServiceFeeFlow; + currency: string; + eligibilityCreatedAt: Date; + eligibleSubtotalMinor: number; + kiloUserId?: string; + organizationId?: string; + stripeCustomerId?: string; +}; +``` + +Decision order: + +1. Validate flow owner and currency. A non-USD eligible event terminates as `unsupported_currency`. +2. Resolve the decision at `eligibilityCreatedAt`. If it precedes activation, the outcome is `pre_activation`. For organization flows, find the latest exemption row at or before that instant; the outcome is `exempt` only when that exact row grants exemption. Never use current exemption state to decide a delayed invoice webhook. +3. Compute `expected_fee_minor` from the eligible subtotal in all cases, including exempt and pre-activation events. Leakage and audit reporting need it. The subtotal is always known at this point: top-ups and Kilo Pass Checkout use list price, invoices use actual draft lines. +4. If the event is pre-activation, outcome is `pre_activation`. +5. If the exact organization is exempt, outcome is `exempt`. +6. If the expected fee is zero, outcome is `zero_rounded`. +7. Otherwise persist outcome `pending` while attaching the provider line. +8. After Stripe accepts the fee line, move to `charged`. For invoice-attached fees set `charged_fee_minor` to the attached amount, which equals `expected_fee_minor`. For Checkout-created fees leave `charged_fee_minor` at zero until settlement reads the settled fee line, because a promotion code may have reduced it. +9. If attachment fails, atomically set outcome `missed`, charged fee zero, and a stable failure code. + +For Checkout, persist after Stripe returns a real session so an abandoned API call does not leave an assessment that looks customer-visible. For invoice handling, insert/upsert before attempting the fee line so failure can be recorded. + +### Upsert and conflict behavior + +- Use `assessment_key` as the primary application idempotency identity. +- Enrich nullable Stripe IDs only when absent or identical. +- If a retry presents a different owner, flow, currency, eligible subtotal, expected fee, or non-null conflicting Stripe ID, throw an assessment conflict error and report it. Do not silently overwrite financial facts. +- A settled assessment is immutable except for additional Stripe links, monotonic cumulative refund fields, and dispute fields. +- `pending` may become `charged`, a terminal omitted outcome, or `missed`. +- `missed` never transitions to `charged`. The omission is not collected later. +- `pre_activation`, `exempt`, `zero_rounded`, and `unsupported_currency` never transition to `charged`. + +### Fail-open wrapper + +Each eligible creation path calls one wrapper that returns either: + +```ts +type FeeApplicationResult = { + assessmentId: string | null; + assessmentKey: string; + outcome: ServiceFeeOutcome; + eligibleSubtotalMinor: number; + expectedFeeMinor: number; + chargedFeeMinor: number; + checkoutLineItem?: Stripe.Checkout.SessionCreateParams.LineItem; +}; +``` + +or a `missed` result. It must catch fee-domain and exemption failures, persist a missed assessment where enough identity is available, send a best-effort Slack alert, and let the base payment path continue without a fee. + +Do not wrap the base Stripe call itself. A failure to create or pay the underlying purchase retains existing behavior. + +## Interactive Checkout integration + +### Top-ups + +Change these functions: + +- `getStripeTopUpCheckoutUrl()` in `apps/web/src/lib/stripe/index.ts` +- `createAutoTopUpSetupCheckoutSession()` in the same file +- `createOrgAutoTopUpSetupCheckoutSession()` in `apps/web/src/lib/organizations/organization-auto-top-up.ts` + +For all three: + +1. Resolve the principal in cents before creating Checkout. For the default top-up price, retrieve the Stripe Price once per request and require a fixed USD `unit_amount`; do not infer principal from the later gross charge. +2. Generate an assessment key. +3. Prepare the service-fee decision using the current creation attempt instant and exact owner. +4. Build the existing principal line unchanged. +5. When the computed fee is positive, append a one-time `price_data` line with product name `Service fee (5%)`, USD amount, fee metadata, and the tax input from `tax.ts`. +6. Put the trusted principal and assessment key in PaymentIntent metadata. Put the same assessment key and flow in Checkout Session metadata. Preserve existing type metadata. +7. Create the Checkout Session. +8. Persist the session ID and Stripe `created` timestamp. Apply the activation-boundary replacement rule only inside the one-minute window. +9. On assessment preparation or fee-line construction failure, create Checkout with only the principal line, persist `missed`, and notify Slack. + +Top-ups have no hosted promotion-code entry, so their Checkout fee equals the settled fee and the effective-rate assertion should always hold exactly. + +For an organization Checkout, resolve the exact-organization exemption before creating the Session. An exempt organization omits the fee line and persists the expected fee plus the exact exemption history link. Personal purchases have no exemption lookup. + +### Personal Kilo Pass Checkout + +Hosted promotion-code entry is retained. The fee is computed from list price before session creation and is allowed to be discounted along with the product, as specified under Discountable initial fee lines. + +Change `kiloPass.createCheckoutSession` in `apps/web/src/routers/kilo-pass-router.ts`: + +1. Resolve the Kilo Pass list price for the requested tier and cadence through `getStripePriceIdForKiloPass()` and retrieve its `unit_amount`. +2. Generate an assessment key and compute the fee from the list `unit_amount`. +3. Keep the recurring Kilo Pass price line and `allow_promotion_codes: true` unchanged (`apps/web/src/routers/kilo-pass-router.ts:2432`). +4. When the computed fee is positive, append one one-time `price_data` fee line. It will be discountable; that is intended. Per the installed SDK, one-time prices in subscription mode appear on the initial invoice only, so this line does not recur. +5. Put assessment metadata on the Checkout Session and on `subscription_data.metadata` so the initial invoice and all later renewals can bind to an assessment. +6. Create the Session, then persist the assessment with the returned `created` timestamp, `eligible_subtotal_minor` from list price, and provisional `expected_fee_minor`. + +Do not persist outcome `charged` at creation time. The fee actually collected is unknown until settlement. + +After Checkout completes, bind the initial invoice to the same assessment from subscription metadata and read the settled fee line: + +- Set `charged_fee_minor` from the marked fee line on the settled invoice, not from the value sent to Stripe. +- Set `settled_product_minor` from the net eligible Kilo Pass lines on the same invoice. +- When `charged_fee_minor` deviates from 5% of `settled_product_minor` by more than one cent, record `service_fee_rate_deviation`, alert Admin Slack, and leave the assessment settled with the observed amounts. Do not issue a corrective charge or refund, and do not grant extra credits. +- A zero settled fee with a zero settled product is outcome `charged` with both amounts zero, not `missed`. + +Renewals for these subscriptions are handled by `invoice.created` and receive an exact non-discountable fee. + +### Seats and KiloClaw + +Do not add fee preparation to: + +- `getStripeSeatsCheckoutUrl()` +- KiloClaw Checkout or KiloClaw subscription changes +- Store Kilo Pass purchase paths +- Manual Kilo Pass for Organizations agreements + +Add regression tests proving these calls remain fee-free. + +## Invoice-created integration + +Add `invoice.created` to `processStripePaymentEventHook()` in `apps/web/src/lib/stripe/index.ts`. Handle it before settlement events. + +### Invoice retrieval + +The webhook object may contain only the first line page or unexpanded references. Retrieve the invoice with all fields needed for classification, then paginate all lines. Preserve the event's `created` only for event telemetry; use `invoice.created` for fee eligibility. + +### Ownership of invoice fee attachment + +Exactly one code path attaches a fee to any given invoice. Automatic top-up invoices are created and paid by Kilo, not by Stripe's automatic advancement: +`apps/web/src/lib/autoTopUp.ts:267-289` calls `invoices.create({ auto_advance: false, ... })`, then `invoiceItems.create()`, then `invoices.pay()`. Those invoices still emit `invoice.created`, and the webhook can be delivered while the creating request is between its create and pay calls. Assessment-key uniqueness does not serialize this, because both paths would derive the same `invoice:` key and race on insert. + +**The creating code owns the fee for Kilo-created invoices. `invoice.created` must skip them.** Skip the invoice when its metadata `type` is `auto-topup` or `org-auto-topup`. Treat this as a named exclusion with its own regression test, not as an incidental consequence of classification order. + +### Classification order + +Classify the invoice into one of: + +1. **Kilo-owned invoice, skip entirely**: invoice metadata `type` is `auto-topup` or `org-auto-topup`. The creating path in `autoTopUp.ts` already attached the fee. Do not create, upsert, or modify an assessment here. +2. Existing Checkout assessment, identified by `serviceFeeAssessmentKey` in subscription or PaymentIntent/invoice metadata. +3. Personal Kilo Pass by known price or Kilo Pass subscription metadata. +4. Kilo Pass for Organizations by `kilo-pass-org` subscription metadata and a self-service agreement. +5. Excluded seat-only, KiloClaw, manual organization agreement, store, or unknown. + +Case 1 must be evaluated first. Unknown invoices are ignored. A recognized eligible invoice with insufficient evidence fails open and records `missed`. + +### Eligible subtotal for invoices + +For Kilo Pass invoices: + +- Include lines whose price is a known Kilo Pass price and whose subscription context is Kilo Pass. +- Include positive and negative Kilo Pass proration lines. +- Exclude service-fee lines. +- Exclude every seat line, including free-seat prices. +- Exclude KiloClaw lines and unrelated invoice items. +- Sum each line's net pretax amount through `getNetPretaxLineAmountMinor()` so discounts represented in both Stripe arrays are counted once. +- Clamp the aggregate at zero, then calculate the fee once. + +Automatic top-up invoices never reach this path; their fee is computed by the creating code from `config.amount_cents`. At settlement the trusted principal is `invoice.metadata.serviceFeePrincipalMinor`, which must match the marked principal invoice item. Do not use `invoice.amount_paid`. + +### Recurring and ordinary draft invoices + +For draft invoices that are not already bound to a Checkout assessment: + +1. Create/upsert `invoice:` assessment using `invoice.created`. +2. Apply the exact organization's exemption history effective at `invoice.created`, not the exemption state at webhook delivery. +3. If outcome is charged, call `stripe.invoiceItems.create()` with `invoice: invoice.id`, the positive fee amount, description `Service fee (5%)`, fee metadata, `discountable: false`, and the tax input from `tax.ts`. +4. Store the returned invoice-item ID. +5. Do not call `invoices.finalizeInvoice()` from the webhook. Let existing Stripe automatic advancement continue. +6. On attachment failure, mark missed, alert Slack, and return success from the webhook. + +If the invoice is already finalized by the time the event is handled, record `missed` with `invoice_not_draft` and acknowledge the event. Do not add the fee to a later invoice. + +### Synchronous subscription updates + +Stripe documents that some subscription creations and updates finalize or attempt payment synchronously, without the normal draft delay. The repository has such paths: + +- `createOrganizationKiloPassCheckout()` with `proration_behavior: 'always_invoice'` (`apps/web/src/lib/kilo-pass-org/stripe-adapter.ts:133`) +- `handleUpdateSeatCount()` when a shared seat/Kilo Pass subscription increases capacity. **This function is defined in `apps/web/src/lib/stripe/index.ts:1789`**, not in `organization-subscription-router.ts`; the router only imports it. Edit it at its definition. +- Kilo Pass cadence/tier transitions that generate an immediate invoice + +Do not rely on `invoice.created` alone for these paths. + +For every eligible synchronous update: + +1. Create an invoice preview with a fixed `proration_date` where Stripe supports it. +2. Compute the net eligible Kilo Pass subtotal from preview lines. +3. Attach the fee to the resulting draft invoice with `invoiceItems.create({ discountable: false })`. Do not use `add_invoice_items`, which cannot express `discountable` in the installed SDK. +4. Use the same `proration_date` in the actual update so preview and actual proration match. +5. Put the assessment key on subscription/update metadata and the fee line. +6. Reconcile the returned/latest invoice to the assessment. +7. If fee preparation fails before the update, perform the base update without a fee, persist missed, and notify Slack. + +`SubscriptionUpdateParams.add_invoice_items` does not expose `discountable` in the installed SDK (verified: `node_modules/stripe/types/SubscriptionsResource.d.ts:1122-1150`). `invoiceItems.create()` does (`InvoiceItemsResource.d.ts:29`), and so does the `Stripe.InvoiceLineItem` read model (`InvoiceLineItems.d.ts:44`). Prefer `invoiceItems.create()` against the draft invoice so `discountable: false` is explicit. Do not rely on an undocumented default for financial correctness. + +When `handleUpdateSeatCount()` changes a shared subscription: + +- Seat increase proration remains excluded. +- The matching increase in organization Kilo Pass capacity is eligible. +- Preview and calculate only Kilo Pass lines. +- A seat-only subscription update gets no assessment and no fee. + +**Do not add Stripe or database round trips inside the advisory lock.** `handleUpdateSeatCount()` runs entirely inside `db.transaction` holding `pg_advisory_xact_lock` on a pooled connection (`apps/web/src/lib/stripe/index.ts:1793-1798`), and already performs `subscriptions.retrieve`, `subscriptions.update`, `finalizeInvoice`, and `invoices.pay` under it. Adding an invoice preview plus assessment writes would extend that hold further. Compute the fee decision and persist the assessment **outside** the transaction, and pass the prepared values in. If a preview must occur inside, record the added hold time as an accepted cost in the PR description. + +### Existing subscriptions + +No subscription backfill should create a charge, proration, or pending invoice item before activation. Recurring fees are added per invoice, not by adding a persistent fee subscription item. This automatically covers existing subscriptions on post-activation invoices without modifying them in advance. + +Add a read-only pre-release audit script that lists active Stripe-managed Personal Kilo Pass and self-service organization Kilo Pass subscriptions and validates that each can be classified. The script must not update Stripe subscriptions. + +## Settlement integration + +Settlement links Stripe objects and records revenue. It does not decide whether to charge. + +### Checkout one-time payments + +In `handleSuccessfulChargeWithPayment()`: + +1. Resolve `serviceFeeAssessmentKey` and `serviceFeePrincipalMinor` from trusted PaymentIntent metadata. +2. Load and validate the assessment against customer, owner, flow, charge, and PaymentIntent. +3. Use `serviceFeePrincipalMinor` as `creditAmountInCents` for manual and setup top-ups. +4. Never use `charge.amount` for credit principal after this feature. +5. Pass principal, charged fee, and gross paid to the email scheduling path. +6. Mark the assessment settled with gross charge, product principal, PaymentIntent, charge, and settlement time. +7. Existing credit transaction idempotency remains keyed by its existing Stripe identity. + +For pre-activation legacy events without service-fee metadata, preserve current settlement behavior. Gate that fallback by the assessment cutoff identity, not `charge.created`: for Checkout payments retrieve the Checkout Session associated with the PaymentIntent and use `session.created`; for invoice-only payments use `invoice.created`. Metadata-free post-activation eligible top-ups are an operational error and must not silently grant gross credits. Record/alert the missing assessment and derive principal only from existing trusted `amountCents` metadata or a marked principal line. + +### Automatic top-ups + +In `invoice.paid` auto-top-up branches: + +1. Load assessment by invoice ID/key. +2. Use assessment `eligible_subtotal_minor` or trusted invoice principal metadata as the credit amount. +3. Do not pass `invoice.amount_paid` to `processTopUp()` or `processTopupForOrganization()`. +4. Mark settlement before scheduling revenue-dependent side effects. +5. Preserve attempt-lock completion/release behavior. +6. Duplicate webhook processing must not duplicate assessment settlement, credits, or emails. + +### Personal Kilo Pass + +At the start of `handleKiloPassInvoicePaid()` after classification: + +1. Resolve and validate the assessment. +2. Mark it settled in the same database transaction as the Kilo Pass invoice-paid mutations. Pass the existing Drizzle transaction into the assessment helper. Do not commit Kilo Pass credits without the corresponding settlement update when an assessment exists. +3. Continue deriving base entitlement from tier config, never gross invoice amount. +4. Replace all product/affiliate amounts currently using `invoice.amount_paid` with the assessment's settled product amount. +5. PostHog purchase telemetry may add separate `service_fee_usd` and `gross_paid_usd` properties, but the existing `amount_paid_usd` product property must exclude the fee. +6. The duplicate-card and welcome-promo positive-settlement checks use the gross Stripe settlement only to determine whether payment occurred. They must not treat the fee as product value. + +Change `enqueueKiloPassAffiliateSaleForInvoice()` to accept an explicit product amount in minor units. Do not recompute it from the invoice. Update `.specs/impact-affiliate-tracking.md` rule 17 so Kilo Pass reported amount explicitly excludes the service-fee line. + +### Kilo Pass for Organizations + +Before `handleOrganizationKiloPassInvoicePaid()` activates an agreement: + +1. Resolve and mark the service-fee assessment settled. +2. Continue selecting the Kilo Pass line through the bound subscription item, not line order. +3. Do not use the fee line for paid period, capacity, or supplement calculations. + +### Settlement meaning + +Set: + +- `settled_at` from Stripe's paid timestamp when available, otherwise webhook observation time. +- `gross_paid_minor` from Stripe's authoritative paid amount. +- `settled_product_minor` to the eligible product subtotal actually settled. Cap it at `eligible_subtotal_minor`; a discounted purchase settles **below** that value, which is expected and is not a mismatch. + +For a charged event, establish collection from the finalized invoice line and invoice settlement state. Do not require cash `amount_paid` to equal product plus fee: Stripe customer credit balances can settle invoice lines without the same cash amount. Record gross cash paid separately, require the marked fee line to be present for `charged_fee_minor`, and require `invoice.status = paid` (or equivalent successful Checkout settlement) before recognizing fee revenue. If the fee line or settlement evidence is missing, report a reconciliation failure and do not recognize the fee until evidence is resolved. + +For exempt and missed outcomes, set settlement and product amount so leakage reporting can count expected values only after payment succeeds. + +## Organization exemption Admin API and UI + +### Router + +Add admin-only procedures to `apps/web/src/routers/organizations/organization-admin-router.ts`: + +```ts +getServiceFeeExemption({ organizationId }); +setServiceFeeExemption({ organizationId, isExempt, reason }); +``` + +Contracts: + +- `reason`: trimmed string, minimum 3, maximum 500 characters. +- `get` returns current state, last reason/actor/time, and history newest first. +- Normalize database timestamps to UTC ISO before returning them. +- `set` runs in one database transaction, takes an organization-scoped advisory transaction lock, and appends one exemption-log row. Current state is that newest row. +- Repeating the same state with a new reason is allowed and creates history because the stated reason is an auditable admin decision. +- Reject deleted or missing organizations. +- Never write `organization_audit_logs`. + +### Hooks + +Add React Query/tRPC hooks in `apps/web/src/app/admin/api/organizations/hooks.ts`. On mutation success, invalidate only the exemption query and admin organization details that display it. + +### Component + +Add `OrganizationAdminServiceFeeExemption.tsx` and render it on `OrganizationAdminDashboard.tsx` near billing controls. + +UI requirements: + +- Use the existing Card, Button, Dialog, Label, Textarea, and status primitives. +- Show `Exempt` or `Fees apply` as compact current state. +- Require a reason in a confirmation dialog for both grant and revoke. +- Action labels are `Grant exemption` and `Revoke exemption`. +- Disable controls and preserve layout while saving. +- Show mutation errors in the dialog and keep the reason for retry. +- Show internal history with actor ID or `Deleted admin`, reason, resulting state, and UTC timestamp rendered in the admin's local timezone. +- Do not expose this component or its data through customer organization routes. +- Verify keyboard focus, Escape behavior, visible labels, 375px layout, and long reason wrapping. + +The design skill has no dedicated card/dialog recipe. Follow its Cloud overlay, interaction-quality, and voice guidance plus neighboring Organization Admin cards. + +### Authorization tests + +Test that: + +- Non-admin procedures cannot read or mutate exemption state. +- A platform admin can grant and revoke with history. +- Blank/oversized reasons fail. +- Parent, child, and member personal purchases do not inherit exemption. +- Customer organization APIs do not include exemption fields. + +## Refund reconciliation + +### Automatic observation + +Extend the `charge.refunded` webhook branch. Fee refund observation must run before branches that return early for affiliate classification. + +1. Resolve the assessment by charge ID, PaymentIntent, or expanded invoice. +2. Retrieve all refunds for the charge if the event does not contain a complete list. +3. Persist the observed cumulative gross refunded amount in a typed `refunded_gross_minor` column, not in `metadata`. Every other financial fact in this table is a typed column and this one is used for reconciliation arithmetic. +4. For Stripe Credit Notes, map refunded eligible product and fee lines directly. +5. A Charge Refund does not carry invoice-line allocation. Treat it as a full refund only when cumulative gross refunded equals the charge amount; then set `refunded_product_minor = settled_product_minor` and `refunded_fee_minor = charged_fee_minor`. +6. For partial Charge Refunds, update principal and fee fields only when Kilo supplied and persisted the allocation at refund creation or an associated Credit Note provides it. Otherwise record `refund_allocation_unresolved`, alert operations, and do not create another Stripe refund automatically. +7. Add `credit_note.created` and `credit_note.updated` webhook handling because the operator procedure in `kilo-org/on-call` uses credit notes for invoice-line attribution. +8. Refund and credit-note webhooks are idempotent and cumulative; later events may resolve a previously partial state. + +### Disputes and chargebacks + +A chargeback removes money from the account, so it must reduce collected fee revenue exactly as a refund does. The webhook plumbing already exists: `charge.dispute.created`, `.updated`, `.closed`, and `.funds_withdrawn` are handled at `apps/web/src/lib/stripe/index.ts:998, 1068-1069, 1354`, and disputes persist to `stripe_dispute_cases` with `amount_minor_units` (`packages/db/src/schema.ts:757-816`). Only the fee-revenue consequence is new. + +Use the dedicated `disputed_fee_minor` column, **not** the refund columns. It is not monotonic: a dispute Kilo wins restores the fee, which would require decrementing `refunded_fee_minor` and breaking its monotonic invariant and tests. Product dispute state remains owned by Stripe and the existing `stripe_dispute_cases` flow. + +1. On `charge.dispute.funds_withdrawn`, resolve the assessment by charge or PaymentIntent. Set `disputed_fee_minor = charged_fee_minor`. A dispute reverses the whole charge, so the full fee is withdrawn even when an earlier partial refund overlaps it. +2. On `charge.dispute.closed` with an outcome in Kilo's favor, reset `disputed_fee_minor` to zero. +3. Reuse the refund arithmetic; do not add a second calculator. +4. Dispute handling is idempotent and must not alter `outcome`. A disputed charge remains `charged`; the money movement is reported separately. + +### Kilo-initiated refunds + +In `apps/web/src/lib/kilo-pass/cancel-and-refund.ts`, the no-amount `refunds.create({ payment_intent })` call already requests a full remaining refund. Keep this behavior. After Stripe returns, update the assessment or rely on the webhook, but never double count. + +If a future Kilo partial-refund path is added, it must call the cumulative refund calculator and issue a gross refund of: + +```text +incremental product refund + incremental service-fee refund +``` + +There is no new partial-refund Admin UI in this change. + +### Operator runbook + +Publish the service-fee operator procedure in `kilo-org/on-call`, which owns Kilo Engineering runbooks. It must cover: + +- How to identify the assessment from a Stripe invoice/charge. +- How to calculate cumulative fee refund. +- How to issue principal plus fee in Stripe. +- Why the system does not auto-correct ambiguous operator refunds. +- How to clear a reconciliation alert after verifying Stripe state. + +## Top-up emails + +Change `sendCreditsTopUpEmail()` and `creditsTopUp.html`. + +### Contract + +Replace ambiguous `amountCents` semantics with explicit fields: + +```ts +{ + principalCents: number; + serviceFeeCents: number; + grossPaidCents: number; + creditsCents: number; +} +``` + +For positive fees, render rows: + +- `Credit principal` +- `Service fee (5%)` +- `Total paid` +- `Credits` +- `Date` + +For fee-free events, omit the fee row. Continue showing the current amount/credits summary without revealing exemption or failure reasons. Build the optional fee row in `email.ts` as escaped `RawHtml`; do not add conditional template syntax. + +Pass amounts from the settled assessment. Do not derive fee as gross minus credits in the email layer. + +Update `apps/web/src/emails/AGENTS.md` template variable documentation and `apps/web/src/lib/purchase-emails.test.ts`. Preserve existing email marker/idempotency behavior. + +No Kilo Pass payment email is added. + +## Revenue and leakage reporting + +### Query model + +`RevenueKpiData` keeps its existing paid/free/multiplier fields and adds a separate settled-fee series: + +- `collected_service_fee_dollars` +- `missed_service_fee_dollars` +- `exempted_service_fee_dollars` +- `disputed_service_fee_dollars` +- `service_fee_charged_count` +- `service_fee_missed_count` +- `service_fee_exempt_count` + +`credit_transactions` remains the sole source for the existing credit-revenue fields. Assessment-backed top-ups remain in those fields, and there is no join or anti-join between assessments and credit transactions. + +Calculate collected fee per assessment as `GREATEST(charged_fee_minor - refunded_fee_minor - disputed_fee_minor, 0)` before summing. Refund and dispute values can overlap when a dispute withdraws the whole charge after a partial refund; overlap must not make a row negative or reduce another row's collected fee. + +Only rows with `settled_at IS NOT NULL` contribute to collected, missed, exempt, or disputed fee values. For missed and exempt amounts use `expected_fee_minor`. The historical charged count includes every settled `outcome = charged` assessment even after refunds or disputes. + +Group service-fee metrics by the UTC calendar date of `settled_at` and push the requested date range into that aggregate. Legacy metrics retain `created_at::date` and its existing session-time-zone semantics. A full outer date merge allows a Kilo Pass assessment to create a fee-only day with zero-valued credit fields, but the assessment does not contribute Kilo Pass product revenue. + +The two series have different coverage and date semantics. Do not add fee figures to credit figures to claim authoritative gross revenue. Kilo Pass product revenue remains outside this query and requires separate product and accounting work. + +### Admin UI + +Update: + +- `apps/web/src/app/admin/components/RevenueStats.tsx` +- `apps/web/src/app/admin/components/RevenueDailyChart.tsx` +- CSV export through the extended response shape + +Keep the existing credit table and chart series under their existing meaning. Show collected, missed, exempted, and disputed fees plus charged, missed, and exempt assessment counts in a clearly separate Service fees section labelled by settled date in UTC. State that these figures do not make the dashboard a complete Stripe product-revenue report and that Kilo Pass product revenue is not added. Use tabular numbers and semantic tokens; keep leakage series subdued. + +Fix two existing empty-data hazards while here: + +- `RevenueStats.tsx:12-13` reads `data[data.length - 1]` with no length check. +- `RevenueDailyChart.tsx:128-137` computes `Math.max(...chartData.map(...))`, which yields `-Infinity` on an empty array. Its CSV export is already guarded. + +### Affiliate reporting + +Service fees are never commissionable. Update every eligible Kilo Pass affiliate/referral conversion amount to use product amount from the assessment. Seat and KiloClaw affiliate behavior is unchanged because they do not carry the fee. + +`.specs/impact-affiliate-tracking.md:150-152` rule 17 currently reads: + +> Kilo Pass SALE amounts MUST use the positive settled invoice paid amount, not catalog price or credit issuance value. + +That directly contradicts excluding the fee, because the settled paid amount now includes it. Amend rule 17 explicitly rather than relying on the new behavior to imply the change. The amended rule must say that Kilo Pass SALE amounts use the settled **eligible product** amount calculated from eligible Stripe product lines, excluding any service-fee line. The same value may support fee settlement, but the assessment does not become an affiliate or product-revenue ledger. + +The implementation dependency is `enqueueKiloPassAffiliateSaleForInvoice()` (`apps/web/src/lib/kilo-pass/affiliate-sale.ts:137`), which currently reports `invoice.amount_paid / 100` at `:167` and gates on `invoice.amount_paid <= 0` at `:144`. Change it to accept an explicit product amount in minor units. Note that `getReportablePromoCode()` at `:123-135` reads `invoice.discounts` and is unaffected by fee changes. + +## Alerts and observability + +Create `sendMissedServiceFeeAlert()` in `alerts.ts` using `sendAdminSlackNotification()`. + +Each fail-open processing attempt sends one alert. Do not persist or check a dedupe marker before sending. A retry sends another alert by design. + +Slack payload includes only: + +- `assessment_key` +- flow +- non-sensitive owner ID +- Stripe object IDs +- eligible subtotal, expected fee, currency +- stable failure code +- processing attempt timestamp + +Do not include metadata dumps, webhook payloads, headers, customer email, billing address, card details, Stripe secret-bearing errors, or Slack URL. + +Wrap Slack in `try/catch`. Capture only `AdminSlackNotificationError.kind/status` and assessment identifiers. Slack failure must not change the assessment outcome or webhook response. + +Use structured Sentry tags for: + +- `service_fee_missed` +- `service_fee_assessment_conflict` +- `service_fee_settlement_mismatch` +- `service_fee_refund_allocation_unresolved` +- `service_fee_rate_deviation` +- `service_fee_restricted_coupon_detected` +- `service_fee_application_failed` + +Do not enable default PII or attach RPC input. + +## Webhook response semantics + +The existing Stripe route returns a non-2xx response when `processStripePaymentEventHook()` throws. Fee attachment has different behavior: + +- Expected fee-domain failures in `invoice.created` are caught, recorded as missed, alerted, and acknowledged with 200. +- A Slack failure is caught and acknowledged with 200. +- A database failure that prevents recording the assessment should still fail open for the payment. Capture and alert if possible, then acknowledge the invoice-created event. This is the one case where durable assessment may be absent; use the assessment key and Stripe metadata for later audit. +- Existing non-fee product handler failures retain their current retry behavior. +- Settlement assessment conflicts throw because continuing could grant the wrong credits or report the wrong revenue. + +This distinction is required: failure to charge the fee must not delay invoice finalization, while failure to identify trusted top-up principal must not grant gross credits. + +## Implementation sequence + +Follow these steps in order. Keep commits small enough to review independently. + +### Phase 0: pin the provider contract + +1. Add an explicit `apiVersion` to `apps/web/src/lib/stripe-client.ts`. +2. Run the existing Stripe test suites and resolve any drift between the account default version and the installed typings. + +Exit condition: the wire API version is pinned and green, as a standalone reviewable commit before any fee logic exists. + +### Phase 1: arithmetic, types, and persistence + +1. Add closed service-fee types/constants. +2. Implement pure fee, line-net, and cumulative-refund calculations with unit tests, including the `type === 'discount'` filter and the zero-product refund guard. +3. Add the two database tables and generated migration, using `assessment_key` as the assessment primary key and `enumCheck()`. +4. Add assessment repository functions and database integration tests. +5. Add exact-organization exemption read/mutation functions and tests. +6. Verify schema, migration bootstrap, package typecheck, and format. + +Exit condition: decisions and assessments can be persisted idempotently without any Stripe path charging a fee. + +### Phase 2: Admin exemption controls + +1. Add admin tRPC get/set procedures. +2. Add hooks and Organization Admin card/dialog/history. +3. Add authorization, mutation, history, and component tests. +4. Deploy this phase before activation and enter historical exemptions. + +Exit condition: an admin can manage exact-organization exemptions with required reasons and internal-only history. + +### Phase 3: top-up Checkout and safe principal settlement + +1. Integrate personal/org manual and setup Checkout lines. +2. Add assessment metadata and boundary handling. +3. Change one-time settlement to trusted principal. +4. Change automatic top-up invoice creation to add fee and principal metadata directly before `invoices.pay()`; because Kilo owns this invoice lifecycle and `auto_advance` is false, create the principal and fee items before payment without waiting for `invoice.created`. Land the `invoice.created` skip rule for `auto-topup` and `org-auto-topup` metadata in the same commit so the two paths can never both attach a fee. +5. Change automatic top-up settlement to trusted principal. +6. Add fee-aware top-up emails. +7. Add flow tests and regression tests for legacy pre-activation events. + +Exit condition: every top-up flow charges correctly without over-crediting. + +### Phase 4: Kilo Pass Checkout, invoices, and mixed subscriptions + +1. **Run the coupon-allocation sandbox matrix first** (see Stripe sandbox contract tests). Percent-off, fixed-amount, product-restricted, and 100%-off coupons against a Kilo Pass Checkout with a fee line. If fixed-amount coupons do not allocate proportionally across product and fee lines, stop: the discountable-fee-line design does not hold and the spec needs revision before code. +2. Add line classifiers and remove first-non-seat assumptions. +3. Integrate Personal Kilo Pass initial Checkout with a list-price fee line. +4. Add `invoice.created` handler for recurring invoices, including the Kilo-owned skip rule. +5. Integrate synchronous Personal Kilo Pass changes that can invoice immediately. +6. Integrate Kilo Pass for Organizations initial add-on and capacity changes. +7. Handle mixed seat/Kilo Pass invoices and organization exemptions. +8. Add settlement linkage, read `charged_fee_minor` from the settled fee line, add the effective-rate assertion, and update affiliate/PostHog product amounts. +9. Add the restricted-coupon detection check and its alert. + +Exit condition: initial, renewal, upgrade, proration, and organization capacity events carry exactly one correct fee; seats never enter the base. + +### Phase 5: refunds, reporting, and operations + +1. Add cumulative refund calculation integration and charge-refunded observation. +2. Update full-refund assessment reconciliation. +3. Add dispute fee reversal on `charge.dispute.funds_withdrawn` and restoration on a won `charge.dispute.closed`. +4. Add fee-only revenue/leakage/dispute query fields without joining assessments to credit transactions. +5. Update Admin revenue components and CSV, including the empty-data fixes and explicit labelling of adjusted versus legacy series. +6. Add missed-fee Slack alerts; publish the response procedure in `kilo-org/on-call`. +7. Add the read-only subscription classification audit script. + +Exit condition: settled fees and leakage reconcile, refunds and disputes reduce revenue, and failures are operationally visible. + +### Phase 6: rollout hardening + +1. Run full targeted test matrix. +2. Use Stripe test clocks or sandbox subscriptions to cross renewal and activation boundaries. +3. Verify historical exemptions are entered. +4. Validate the confirmed mirrored `tax_behavior` in Stripe test mode and retain the decision record in ADR 0004. +5. Audit live Stripe coupons for `applies_to` restrictions covering fee-bearing products. +6. Deploy before activation and monitor assessment outcomes. +7. At activation, verify one example of each eligible flow and one excluded seat/KiloClaw flow. +8. Keep rollback artifact/commit ready; do not add a runtime switch. + +## Test plan + +Use the lightest honest test for each contract. Pure arithmetic and classification belong in unit tests. Persistence, webhook idempotency, and reporting queries require the test PostgreSQL database. A small Stripe sandbox suite validates provider behavior that mocks cannot prove. + +### Pure unit tests + +Add `apps/web/src/lib/service-fees/calculation.test.ts` and classifier tests for: + +- `0 -> 0` +- `$0.01 -> $0.00` +- `$0.10 -> $0.01` at the half-cent boundary +- `$19.00 -> $0.95` +- `$49.00 -> $2.45` +- `$199.00 -> $9.95` +- `$100.00 -> $5.00` +- Aggregate rounding differs from per-line rounding and aggregate wins +- Positive and negative Kilo Pass prorations net before fee calculation +- Discount amounts reduce eligible line value +- A `pretax_credit_amounts` entry of type `discount` reduces the line; an entry of type `credit_balance_transaction` does **not** +- A line carrying both `discount_amounts` and a matching discount pretax-credit entry is subtracted once, not twice +- Seat-only discounts do not touch Kilo Pass value +- Tax fields do not enter subtotal +- Service-fee lines are excluded +- Pagination combines all invoice lines +- Cumulative partial refunds have no drift and end at the full original fee +- Cumulative refund with zero original product returns zero rather than dividing by zero +- Invalid/non-safe integers fail explicitly + +### Database integration tests + +Cover: + +- One assessment key under concurrent inserts +- Stale pending rows are observable and cannot settle or trigger later fee collection +- Retry enrichment of Stripe IDs +- Conflicting owner/amount/Stripe ID rejection +- Eligibility, outcome, and amount checks +- Pending can finalize to charged, an omitted terminal outcome, or missed; terminal outcomes cannot be recollected +- A charged assessment tolerates `charged_fee_minor < expected_fee_minor` without violating a constraint, which is the discounted-Checkout case +- Settlement idempotency +- Monotonic refund updates +- Disputed fee set on funds withdrawn and cleared on a won dispute, without altering `outcome` and without breaking refund monotonicity +- Exact organization exemption and no hierarchy inheritance +- Grant/revoke history ordering and actor nulling semantics +- Settled-only revenue recognition +- Refunds and disputes reduce collected fee revenue +- Unpaid missed/exempt rows do not enter dashboard totals +- Assessment-backed credit transactions retain the existing paid/free/multiplier semantics; fee metrics remain separate + +### Checkout and webhook tests + +Extend existing suites: + +- `apps/web/src/lib/stripe/index.test.ts` +- `apps/web/src/lib/autoTopUp.test.ts` +- `apps/web/src/routers/organizations/organization-auto-top-up-router.test.ts` +- `apps/web/src/routers/kilo-pass-router.test.ts` +- `apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.test.ts` +- `apps/web/src/lib/kilo-pass-org/stripe-adapter.test.ts` +- `apps/web/src/lib/kilo-pass/stripe-invoice-classifier.server.test.ts` +- `apps/web/src/lib/kiloclaw/stripe-invoice-classifier.server.test.ts` + +Workflows: + +1. Checkout created one second before activation has no fee. +2. Checkout created exactly at activation has the fee. +3. Invoice created one second before and exactly at activation follows the same boundary. +4. Request straddling activation expires/replaces the incorrect Checkout once, and a request outside the one-minute window performs no expire/replace round trip. +5. Personal/org manual and setup top-ups grant principal, not gross. +6. Automatic top-up adds principal and fee before pay and grants principal. +7. **`invoice.created` for an `auto-topup` or `org-auto-topup` invoice creates no assessment and attaches no second fee item**, including when the webhook is delivered between `invoices.create()` and `invoices.pay()`. +8. Exempt org top-up has no line but has settled exempt assessment. +9. Personal Kilo Pass initial Checkout has recurring product plus one-time fee computed from list price. +10. Existing subscription renewal receives a draft invoice fee equal to expected. +11. A discounted Kilo Pass Checkout settles with `charged_fee_minor` below `expected_fee_minor` and an effective rate within one cent of 5%, recorded as `charged` without a deviation alert. +12. A 100% coupon settles as `charged` with zero product and zero fee, not `missed`. +13. A simulated product-restricted coupon produces an effective rate above 5% and raises `service_fee_rate_deviation` without issuing a corrective charge or refund. +14. Mixed seats plus Kilo Pass charges only Kilo Pass. +15. Organization capacity increase charges only the Kilo Pass proration. +16. Negative/zero net Kilo Pass proration omits the fee. +17. Seat-only, KiloClaw, store, and manual agreement events create no assessment. +18. Fee-line presence does not alter product classifiers or bound subscription item lookup. +19. Invoice-created fee attachment failure returns normally, records missed, and calls Slack once per invocation. +20. Slack failure returns normally. +21. Tax-resolution or fee-application failure records missed with `fee_application_failed` and the payment still proceeds. +22. Retry does not attach a second fee line; a missed retry alerts again but never attempts later collection. +23. Paid webhooks link invoice, PaymentIntent, and charge to one assessment. +24. Duplicate paid webhook does not duplicate credits, revenue, email, or affiliate event. +25. Affiliate sale and product analytics exclude the fee. +26. Full Kilo Pass refund observes the complete fee refund. +27. Ambiguous partial operator refund records unresolved allocation without issuing another refund. +28. Dispute funds withdrawn removes the fee from collected revenue; a won dispute restores it. + +### Email and UI tests + +- Fee-positive top-up email contains principal, exact fee label, total paid, and credits. +- Fee-free email omits the fee row. +- Existing email marker prevents duplicates. +- Admin exemption card renders loading, current, error, grant, revoke, and history states. +- Reason is required and retained after a failed mutation. +- Revenue totals handle empty data and show separate product/fee/gross/leakage values. + +### Stripe sandbox contract tests + +Mocks cannot establish Stripe's discount, tax, draft-finalization, or line-allocation behavior. Before release, use Stripe sandbox/test clocks to prove: + +**Coupon allocation matrix — run this before writing Phase 4 code.** The discountable-fee-line design depends on Stripe allocating a coupon proportionally across the product and fee lines. That is standard behavior and consistent with per-line `discount_amounts`, but it is inferred from the API surface and is not provable from this repository. Prove each case against a real Kilo Pass Checkout carrying a fee line: + +| Coupon | Expected result | +|---|---| +| 20% off, unrestricted | Fee discounted proportionally; effective rate exactly 5% of net product | +| Fixed $10 off, unrestricted | Allocated across both lines; effective rate exactly 5% of net product | +| Percent off restricted via `applies_to.products` | Product discounted, fee not; effective rate above 5% and deviation alert fires | +| 100% off | Both lines zero; assessment `charged` with zero amounts | + +If fixed-amount coupons do not allocate proportionally, this design does not hold and the spec must be revised before implementation. Record the observed allocation for each case in the PR. + +Also prove: + +- Checkout fee line label and metadata, including `price_data.product_data.metadata` survival onto the generated invoice line. +- A draft renewal invoice accepts the non-discountable fee before finalization. +- Synchronous subscription update/proration includes the fee on the same invoice and explicitly marks it non-discountable. +- Mirroring the eligible Price's `tax_behavior` onto the fee line yields the same treatment on both lines. +- Hosted invoice and PDF show one `Service fee (5%)` line. +- Full refund returns product, fee, and tax according to Stripe's configured behavior. + +If any provider contract differs from this spec, do not compensate with post-settlement credit math. Revise the Stripe construction so the customer is charged correctly. + +## Verification commands + +Read root, web, and DB package manifests before running commands. Start/migrate test PostgreSQL if required: + +```bash +docker compose -f dev/docker-compose.yml ps postgres +pnpm test:db # only when the repository-managed DB is not ready +``` + +During implementation, run narrow checks: + +```bash +pnpm --filter @kilocode/db typecheck +pnpm --filter web typecheck +pnpm --filter web test -- --runInBand +pnpm drizzle:verify-bootstrap +scripts/typecheck-all.sh --changes-only +pnpm --filter web lint +pnpm format +pnpm format:check +git diff --check +``` + +`packages/db` has no `test` script. `packages/db/src/schema.test.ts` runs under web's Jest config, which includes `/../../packages/db/src/**/*.test.ts` (`apps/web/jest.config.ts:38`), so run it through `pnpm --filter web test`. + +Report targeted checks as targeted, not as full validation. + +Before the final commit, run React Doctor because this work changes React admin/revenue components. Compare its score to the starting score and fix regressions caused by this change. React Doctor is an agent-environment capability, not a repository script; do not present it as a repository command in the PR. + +## File impact checklist + +Expected new files: + +- `apps/web/src/lib/service-fees/constants.ts` +- `apps/web/src/lib/service-fees/types.ts` +- `apps/web/src/lib/service-fees/calculation.ts` +- `apps/web/src/lib/service-fees/stripe-lines.ts` +- `apps/web/src/lib/service-fees/assessments.ts` +- `apps/web/src/lib/service-fees/checkout.ts` +- `apps/web/src/lib/service-fees/invoice-created.ts` +- `apps/web/src/lib/service-fees/settlement.ts` +- `apps/web/src/lib/service-fees/refunds.ts` +- `apps/web/src/lib/service-fees/tax.ts` +- `apps/web/src/lib/service-fees/alerts.ts` +- `apps/web/src/lib/service-fees/organization-exemptions.ts` +- `apps/web/src/lib/service-fees/disputes.ts` +- Corresponding focused tests +- `apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx` +- One generated migration and generated metadata +- A read-only pre-release classification audit script +- A read-only restricted-coupon audit check +- A corresponding operator partial-refund runbook in `kilo-org/on-call` + +Expected modified files: + +- `apps/web/src/lib/stripe-client.ts` — pin `apiVersion` (Phase 0) +- `packages/db/src/schema.ts` +- `packages/db/src/schema-types.ts` if shared enum exports are used +- `apps/web/src/lib/stripe/index.ts` — includes `handleUpdateSeatCount()` at line 1789 +- `apps/web/src/lib/autoTopUp.ts` +- `apps/web/src/lib/organizations/organization-auto-top-up.ts` +- `apps/web/src/lib/credits.ts` +- `apps/web/src/lib/organizations/organization-billing.ts` +- `apps/web/src/routers/kilo-pass-router.ts` +- `apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts` +- `apps/web/src/lib/kilo-pass/affiliate-sale.ts` +- `apps/web/src/lib/kilo-pass/cancel-and-refund.ts` +- `apps/web/src/lib/kilo-pass-org/stripe-adapter.ts` +- `apps/web/src/routers/organizations/organization-subscription-router.ts` +- `apps/web/src/routers/organizations/organization-admin-router.ts` +- `apps/web/src/app/admin/api/organizations/hooks.ts` +- `apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx` +- `apps/web/src/lib/email.ts` +- `apps/web/src/emails/creditsTopUp.html` +- `apps/web/src/emails/AGENTS.md` +- `apps/web/src/lib/revenueKpi.ts` +- `apps/web/src/app/admin/components/RevenueStats.tsx` +- `apps/web/src/app/admin/components/RevenueDailyChart.tsx` +- `.specs/impact-affiliate-tracking.md` +- Existing focused test files named above + +Do not change pre-purchase Kilo pricing UI, Kilo billing-history itemization, Kilo Pass payment email behavior, store billing, seat pricing, or KiloClaw pricing. + +## Completion checklist + +Implementation is complete only when all statements are true: + +- [ ] Every included flow has a positive Stripe fee line when eligible. +- [ ] Every excluded flow remains fee-free. +- [ ] The cutoff uses returned Checkout creation time or invoice creation time exactly. +- [ ] Existing subscriptions receive post-activation invoice fees without pre-activation prorations. +- [ ] Discounts and proration credits produce an exact aggregate net 5% fee, whether by proportional allocation on Checkout or by exact calculation on invoices. +- [ ] The Stripe wire `apiVersion` is explicitly pinned. +- [ ] Exactly one code path attaches a fee to any given invoice, proven for Kilo-created auto-top-up invoices. +- [ ] Prepaid credit-balance consumption does not shrink the fee base. +- [ ] Mixed seat/Kilo Pass invoices never include seats in the base. +- [ ] Credits and entitlements use product principal, never gross paid amount. +- [ ] Exact-organization exemptions are admin-only, reasoned, audited, and non-inherited. +- [ ] One durable assessment links all related Stripe identities. +- [ ] Fee failure proceeds without the fee, never retries collection, and alerts on every processing attempt. +- [ ] Slack failure cannot block payment. +- [ ] Tax-resolution or fee-application failure fails open in production without blocking payment. +- [ ] Collected fee revenue is recognized only after settlement and reduced by refunds and disputes. +- [ ] `charged_fee_minor` comes from the settled Stripe fee line, never from the value sent. +- [ ] The effective-rate deviation alert fires for a restricted coupon and no corrective charge or refund is issued. +- [ ] Missed and exempted values count only after underlying settlement. +- [ ] Affiliate amounts exclude the fee and rule 17 is amended. +- [ ] Full and cumulative partial refund calculations have no rounding drift. +- [ ] Top-up emails itemize positive fees and omit fee-free rows. +- [ ] Fee lines cannot be mistaken for seats, Kilo Pass, KiloClaw, or credit principal. +- [x] Finance confirmation of the mirrored tax treatment is recorded in ADR 0004. +- [ ] The coupon-allocation sandbox matrix is recorded before activation. +- [ ] Historical exemptions are entered and verified before activation. +- [ ] Live Stripe coupons are audited for `applies_to` restrictions on fee-bearing products. +- [ ] Rollback requires deployment rollback; no runtime global switch exists. diff --git a/.plans/service-fees/VALIDATION.md b/.plans/service-fees/VALIDATION.md new file mode 100644 index 0000000000..c7a1e00211 --- /dev/null +++ b/.plans/service-fees/VALIDATION.md @@ -0,0 +1,1806 @@ +# Service fee end-to-end validation + +## Status and authority + +Browser-and-database validation contract for `.plans/service-fees/GOAL.md` and +`.plans/service-fees/SPEC.md`. + +`GOAL.md` is authoritative for product behavior. `SPEC.md` is authoritative for +technical design. This file is authoritative for **how we prove the behavior +with a real local app, a real Stripe test-mode Checkout, the local PostgreSQL +database, and a video of each journey**. + +This is not a substitute for the unit, database, webhook, and Stripe sandbox +suites in `SPEC.md`. Those remain required. This file covers only what a person +(or an agent driving a browser) can honestly observe by acting as a user or +admin and then inspecting durable state. + +Implementation is present, but the browser/database/video evidence pack is not yet complete. +Labels, routes, and table names below reflect the current app plus the spec. If implementation +changes a user-visible label or a column name, update this file in the same change. + +## What "done" means here + +A journey passes only when all four are true: + +1. An operator followed the listed actor steps in a real browser session. +2. The UI showed the expected customer or admin outcome. +3. Local PostgreSQL contained the expected assessment, credit, exemption, or + revenue rows. +4. Each required clip was produced by loading and following the + `video-evidence` skill, and that skill's output contract is attached to the + journey evidence pack. + +A screenshot, a unit test, or a mocked Stripe client is not evidence for this +file. Stripe Dashboard or CLI output is supporting evidence, not a replacement +for the browser session. An unedited raw WebM, a screen recording that includes +login or debugging, or a file written inside the repository is not a passing +video artifact. + +## What this file does not cover + +These are required by `SPEC.md` and must stay in automated tests. They are not +honest user journeys: + +- Pure rounding and pretax-line arithmetic +- Concurrent assessment-key inserts +- Webhook retry / idempotency storms +- `invoice.created` racing a Kilo-owned auto-top-up invoice +- Slack alert payload shape and Slack-failure isolation +- Restricted-coupon scheduled audit script +- Store-managed App Store / Google Play Kilo Pass +- Manual or sales-assisted organization Kilo Pass agreements + +Hybrid journeys (renewal, dispute, operator partial refund) still start from a +browser-created purchase. The non-browser step is named and then the result is +verified again in the browser and the database. + +--- + +## 1. Environment + +### 1.1 Local stack + +Reuse the current worktree session. Do not start a second stack. + +```bash +pnpm dev:status --json +``` + +Required: + +| Service | Why | +|---|---| +| Web app on the reported port | Every browser step | +| Worktree Postgres from `.env.local` `POSTGRES_URL` | Assessment, credit, exemption, revenue checks | +| Stripe webhook forwarder | Settlement, emails, subscription activation | +| Applied service-fee migration | Assessment and exemption tables exist | + +Confirm webhook forwarding is live (`pnpm --filter web stripe` if `dev:start` +skipped it). Without it, Checkout can succeed and the database will stay empty. +`pnpm dev:status --json` may report the portless Stripe listener as `down` even +while `stripe listen` is forwarding correctly. Check the actual listener +process and its `--forward-to` URL. The URL must use this worktree's web port. + +Read the web port from `pnpm dev:status`. Never assume `3000`. Worktree +PostgreSQL also uses a worktree-specific port; use `POSTGRES_URL` rather than a +root-worktree or default `5432` value. + +```text +BASE=http://localhost: +``` + +### 1.2 Activation clock + +Fees apply only to billing objects created at or after +`2026-11-01T00:00:00Z` (`SERVICE_FEE_ACTIVATION_UNIX_SECONDS`). + +| Wall clock | How to run this file | +|---|---| +| On or after 2026-11-01 UTC | Use the committed constant. Run V30 only if a pre-activation fixture still exists. | +| Before 2026-11-01 UTC | Keep the committed constant for V30. For every "fee applies" journey, use a **local uncommitted** override of `SERVICE_FEE_ACTIVATION_UNIX_SECONDS` to `0`. Record the override in the run log. Revert it before any commit. | + +There is no production runtime switch. The override is a validation-only local +edit. If a journey is run before activation without the override, a +`pre_activation` outcome is a pass for V30 and a fail for every fee-applies +journey. + +Treat the override as a guarded local fault: + +1. Record the committed value in `evidence/RUN.md` before changing it. +2. Run V30 before applying the override when the wall clock permits. +3. Keep the override uncommitted and mark it clearly in the source line. +4. Before pausing, handing off, committing, or ending the run, restore the + committed value. +5. Restart the web process after restoration. If restart cannot complete, + stop the process and record that it is stopped; do not leave a process + running with the override loaded. +6. Confirm the source diff no longer contains the override. + +### 1.3 Stripe test mode + +Use the account already configured in `.env.local`. + +| Item | Value | +|---|---| +| Success card | `4242 4242 4242 4242` | +| Expiry | Any future month | +| CVC | Any 3 digits | +| ZIP | Any 5 digits | +| Decline card | `4000 0000 0000 0341` (negative payment only; not a fee journey) | + +Before V8, V9, and V32, create these Stripe test-mode promotion codes. They are +operator fixtures, not product features: + +| Code | Coupon | Restriction | +|---|---|---| +| `VALIDATE20` | 20% off, unrestricted, redeemable on Checkout | None | +| `VALIDATE100` | 100% off, unrestricted | None | +| `VALIDATEKPONLY` | 20% off, `applies_to` = Personal Kilo Pass product only | Must not apply to the fee product | + +Never leave `VALIDATEKPONLY` on a live coupon that customers can discover. +Delete it after V32. + +If Stripe Tax is enabled on the test account, Checkout totals include tax. +Assert pretax line amounts and assessment columns, not the Checkout grand +total. + +#### 1.3.1 Price tax-behavior audit + +Before any Kilo Pass journey, retrieve every configured Kilo Pass Price used by +the run. Each Price must have an explicit Stripe `tax_behavior` of `inclusive` +or `exclusive`. `unspecified` is a release blocker: the service-fee code must +fail open because it cannot mirror an unknown product treatment. + +```bash +stripe prices retrieve \ + | jq '{id,active,product,unit_amount,currency,recurring,tax_behavior}' +``` + +Audit all monthly and yearly tier Prices, not only the tier selected in V7. +Record the IDs and results in `evidence/v0.md`. A successful local journey with +a replacement Price does not clear the release blocker for configured +production or shared test Prices. + +Do not mutate a shared Price merely to make validation pass. If a configured +test Price is `unspecified`, create a disposable test-mode Price only after the +intended explicit tax behavior is known. It must use the same product, +currency, amount, and recurrence. Override only the ignored local environment, +restart the web process, record the original and replacement IDs, and archive +the disposable Price and remove the override after the Kilo Pass journeys. + +An unpaid Checkout that exercises the `unspecified` path is diagnostic +evidence only. Expire it, verify no entitlement or payment was created, and +record the expected `missed` assessment separately from accepted journey +evidence. + +#### 1.3.2 Kilo Pass payment fingerprints + +Kilo Pass has an intentional duplicate-card anti-abuse path. A fresh user is +not sufficient isolation: reusing the same Stripe test-card fingerprint across +V7, V8, V9, or V32 can block the later user, cancel the subscription, and +refund the charge before entitlement issuance. + +Assign a different Stripe test payment method to every Kilo Pass buyer. Use +Stripe-documented test methods and verify the resulting +`payment_method_details.card.fingerprint` differs from earlier Kilo Pass +charges. Record only the non-secret test method label and fingerprint in +`evidence/v0.md`; never record real payment data. If duplicate-card protection +fires, preserve the refund/reconciliation evidence, mark the journey +`BLOCKED`, and repeat with a fresh user and a genuinely different fingerprint. +Do not disable anti-abuse logic to force a pass. + +### 1.4 Personas + +Create fresh fake users for each validation run. Do not reuse leftover Stripe +customers from earlier experiments. V7, V8, V9, and V32 each require a separate +user with no Kilo Pass and a separate payment fingerprint. Add those +supplemental personas to the run log; do not overload `P-PERSONAL` after V7. + +| Persona | Fake email pattern | Role | +|---|---|---| +| `P-ADMIN` | `kilo--@admin.example.com` | Platform admin | +| `P-OWNER` | `kilo---owner@example.com` | Org owner / billing | +| `P-MEMBER` | `kilo---member@example.com` | Org member, no billing | +| `P-CHILD` | `kilo---child@example.com` | Owner of a child org | +| `P-PERSONAL` | `kilo---solo@example.com` | No org, personal top-ups | +| `P-V7` | `kilo---v7@example.com` | Fresh Personal Kilo Pass buyer | +| `P-V8` | `kilo---v8@example.com` | Fresh 20% promo buyer | +| `P-V9` | `kilo---v9@example.com` | Fresh 100% promo buyer | +| `P-V32` | `kilo---v32@example.com` | Fresh restricted-coupon buyer | + +Login: + +```text +$BASE/users/sign_in?fakeUser=&callbackPath= +``` + +Wait until the account-creation spinner finishes. If `/customer-source-survey` +appears, click `Skip`. + +Admin bootstrap requires the `@admin.example.com` suffix. A fake +`someone@kilocode.ai` is not an admin. + +### 1.5 Organizations + +Create from `$BASE/organizations/new` while signed in as `P-OWNER`: + +| Org | Name | Purpose | +|---|---|---| +| `ORG-A` | `Fee Validate A ` | Fee-paying org | +| `ORG-B` | `Fee Validate B ` | Exemption target | +| `ORG-CHILD` | created from `ORG-B` admin hierarchy as a child | Proves exemptions do not inherit | + +After creating `ORG-A` and `ORG-B`, invite `P-MEMBER` to `ORG-B` as a member +without billing permission. Create `ORG-CHILD` from +`/admin/organizations/` hierarchy controls, then make `P-CHILD` its +owner. + +Record each org UUID in the run log. All later SQL uses those IDs. + +### 1.6 Historical exemptions and release audits + +Browser journeys prove that exemption management and coupon detection work; +they do not populate or clear production release gates. + +Before production activation: + +1. A platform admin must enter every approved historical organization + exemption and its reason through the Admin UI. Do not infer exemptions from + plan, seats, sponsorship, hierarchy, trial state, or a source-controlled + allowlist. +2. A second operator must verify each current exemption against the approved + source and confirm that the newest exemption row is the expected state. + Record the organization IDs, exemption IDs, and verification time without + copying contract text or unrelated customer data into the evidence pack. +3. Run the read-only Kilo Pass classification audit using the service-fee + runbook in `kilo-org/on-call` against the release environment. +4. Run the read-only restricted-coupon audit from that runbook. A finding is a + release blocker. Delete or replace the unsafe coupon and rerun until clean. +5. Never add mutating flags or turn the audit into an implicit cleanup tool. + Dashboard coupon changes require the audit to be rerun. + +Record command names, environment, timestamps, exit status, and redacted +findings in `evidence/RUN.md`. Local `VALIDATEKPONLY` evidence does not satisfy +or replace the live read-only audit. + +### 1.7 Database access + +```bash +set -a && source .env.local && set +a +psql "$POSTGRES_URL" +``` + +Do not use port `5432` unless `POSTGRES_URL` says so. A worktree database is +empty until migrated. + +Identifying the current actor: + +```sql +SELECT id, google_user_email, is_admin +FROM kilocode_users +WHERE google_user_email LIKE 'kilo-%@%' +ORDER BY created_at DESC; +``` + +--- + +## 2. Recording and evidence + +### 2.1 Video is produced only through the `video-evidence` skill + +Every required clip in this file **must** be captured, edited, and validated by +loading and following the `video-evidence` skill. Do not improvise a recording +procedure, and do not treat a raw `agent-browser record` WebM as the final +artifact. + +Before any browser command for a clip: + +1. Load the `video-evidence` skill and follow it completely. Do not invent a + shorter recording procedure from this file. +2. From that skill, load the installed agent-browser workflow so commands + match that version: `agent-browser skills get core --full`. +3. Follow the skill for workspace, shot list, dry-run, capture, inspection, + edit, technical validation, privacy, and the output contract. + +The skill is the source of truth for how video is made. This file only names +which clips exist, what each clip must prove, and what it does not prove. + +Non-negotiable mappings from that skill onto this validation run: + +- One source take per final clip. Split personas, permissions, and unrelated + scenarios. A journey that needs an admin action and a customer action is at + least two clips. +- Dry-run the journey without recording. Record only after selectors, Stripe + redirect, settlement wait, and final UI are known to work. +- Do not record service startup, seeding, fake login, account or org creation, + Stripe promo-code setup, SQL, logs, selector repair, or failed attempts. + Navigate to the ready starting page, then start recording. +- Keep media outside the repository. Use the skill's temporary working + directory. Do not write raw or final video under `.plans/`, `apps/`, or any + other repo path unless a later human explicitly asks to commit a specific + file. +- Final artifact is the skill's validated H.264 MP4 (`yuv420p`, even + dimensions, `+faststart`), not the raw WebM. +- Stripe test card `4242…` is non-secret test data and may appear. Never record + real card numbers, auth cookies, `POSTGRES_URL`, webhook secrets, or live + customer data. +- If a recorded take needed troubleshooting or material intervention, discard + it, restore state, and record a clean take. Editing must not turn an assisted + run into apparent success. +- Start a clean take by giving `record start` the ready starting URL. Recorder + startup may reload the page or close a transient dialog. Refresh the + accessibility snapshot after recording starts, then open the dialog or + perform the first action on camera. A coordinate click is acceptable only + when it targets the same visible control and the reason is documented; all + subsequent interaction should use refreshed semantic references. +- Use a 1440x1200 viewport for hosted Checkout unless the journey explicitly + tests another size. If Stripe opens Link, choose `Pay without Link`. Leave + the optional phone field empty and enter the billing address manually. +- Stripe Checkout conditionally renders controls. Use `check`, not a plain + click, for the first AI-agent checkbox, refresh the snapshot, then check the + newly rendered acknowledgment. Refresh references after every conditional + state change rather than reusing stale refs. +- If Stripe presents a local-currency conversion, select USD when available so + the source amounts are readable. Stripe API and PostgreSQL minor units remain + authoritative when the hosted page still shows converted amounts. +- A browser-command timeout during a static redirect or processing wait does + not prove payment failure. Check the current URL, Stripe Session or invoice, + assessment state, and final Kilo UI before deciding the outcome. +- Inspect video locally. Use `ffprobe`, full-decode checks, targeted frames, + scene/freeze analysis, and local OCR. Do not send full-resolution frames or + contact sheets to the model. If an image is ever necessary, use only a + targeted low-resolution frame, normally 480 px wide and below about + 1000x800. Keep original-resolution evidence on disk. +- Cuts may remove static setup, redirect, or processing waits, but must preserve + chronology. Do not reorder events or combine actions from different + commercial events. +- If cross-origin capture or animation prevents one clip from showing the whole + result, use complementary clips only when they refer to the same durable + event. Record the shared Stripe or assessment identity and state exactly what + each clip proves. Do not imply that either clip alone proves the full journey. +- A failed product outcome may be kept as diagnostic evidence. It still needs + the skill's output contract and must be labeled `FAIL` or `BLOCKED`, not + edited into a pass. + +Checkout-specific proof points the skill's shot list must include when the +journey charges or omits a fee: + +1. Kilo starting page with the purchase control visible. +2. Initiating click (`$100`, `Buy now`, `Enable automatic top up`, and so on). +3. Stripe Checkout or hosted invoice with every relevant line readable, + including `Service fee (5%)` or its proven absence. +4. Return to Kilo and the user-visible result (credits, subscription, toast, + exemption state). +5. A reload or revisit when persistence is part of the claim. + +State in the shot list what the clip does **not** prove (other accounts, +webhooks, SQL, emails not shown, other orgs). + +### 2.2 Evidence pack + +Written notes may live in `.plans/service-fees/evidence/.md`. +Video files must not. + +```markdown +# +- Started: +- Actor emails: +- Org IDs: +- Stripe Checkout / invoice / charge IDs: +- Assessment key: +- Result: PASS | FAIL | N/A | BLOCKED +- Notes: + +## Video-evidence output record +Paste one skill output contract per clip: + +path: +label: +claim: +limits: +source_duration: +final_duration: +size_bytes: +format: +validation: +repository_state: not committed +``` + +Paste the SQL result and the Stripe line summary into that file. The video +`path` is the absolute path returned by the skill, outside the repository. +Keep diagnostic events, dry runs, and accepted commercial events distinct. A +dry run that proves selectors or a failure path does not become PASS evidence +for a later successful event, even if its Stripe amounts were correct. + +### 2.3 Shared browser session names + +| Session | Persona | +|---|---| +| `sf-admin` | `P-ADMIN` | +| `sf-owner` | `P-OWNER` | +| `sf-member` | `P-MEMBER` | +| `sf-child` | `P-CHILD` | +| `sf-personal` | `P-PERSONAL` | + +Reuse a named session across journeys for the same persona so login is not +repeated on camera. Authenticate and land on the starting page **before** +`record start`. Each clip still gets its own raw take and final MP4. + +--- + +## 3. Shared verification helpers + +Run these after every payment journey. Values in angle brackets come from that +journey. + +### 3.1 Wait for settlement + +Do not query immediately after Stripe says paid. The success page polls; the +webhook may still be in flight. + +Poll up to 30 s: + +```sql +SELECT assessment_key, flow, outcome, + currency, + eligible_subtotal_minor, expected_fee_minor, charged_fee_minor, + settled_product_minor, gross_paid_minor, + refunded_product_minor, refunded_fee_minor, + disputed_fee_minor, + stripe_checkout_session_id, stripe_invoice_id, + stripe_payment_intent_id, stripe_charge_id, + failure_code, settled_at, exemption_id +FROM stripe_service_fee_assessments +WHERE kilo_user_id = '' + OR organization_id = '' +ORDER BY created_at DESC +LIMIT 5; +``` + +A journey that expects a charged fee is not settled until `outcome = 'charged'`, +`settled_at IS NOT NULL`, and `stripe_charge_id` or `stripe_invoice_id` is set. + +### 3.2 Credits must equal principal + +```sql +SELECT id, amount_microdollars, stripe_payment_id, description, created_at +FROM credit_transactions +WHERE kilo_user_id = '' + OR organization_id = '' +ORDER BY created_at DESC +LIMIT 5; +``` + +`amount_microdollars` is principal only. A `$100.00` top-up is `100000000`, +never `105000000`. Join to the assessment with: + +```sql +SELECT a.assessment_key, a.settled_product_minor, a.charged_fee_minor, + a.gross_paid_minor, ct.amount_microdollars, + ct.stripe_payment_id +FROM stripe_service_fee_assessments a +JOIN credit_transactions ct + ON ct.stripe_payment_id IN ( + a.stripe_charge_id, + a.stripe_invoice_id, + a.stripe_payment_intent_id + ) +WHERE a.assessment_key = ''; +``` + +Expect `ct.amount_microdollars = a.settled_product_minor * 10000` for top-ups +(microdollars vs minor units). Kilo Pass entitlement is not this table; check +the Kilo Pass UI and `kilo_pass` subscription state instead. + +### 3.3 Stripe line item + +From the assessment's Checkout or invoice ID: + +```bash +stripe checkout sessions retrieve +stripe invoices retrieve +``` + +Pass only if there is exactly one line whose description is `Service fee (5%)` +and whose amount equals `charged_fee_minor` after settlement. Exempt, +pre-activation, zero-rounded, and missed events must have **zero** such lines. +Stripe must not show a `$0.00` or "waived" fee line. + +### 3.4 Top-up email + +Local mail is written under `dev/logs/emails/`, not sent. + +```bash +ls -lt dev/logs/emails | head +``` + +Open the newest HTML for that user. Fee-positive mails must contain rows +labelled `Credits added`, `Service fee (5%)`, and `Total paid`. +Fee-free mails omit the fee row and must not mention exemption, activation, or +failure. + +### 3.5 One assessment per commercial event + +```sql +SELECT assessment_key, count(*) +FROM stripe_service_fee_assessments +WHERE stripe_checkout_session_id = '' + OR stripe_invoice_id = '' + OR stripe_payment_intent_id = '' + OR stripe_charge_id = '' +GROUP BY assessment_key; +``` + +Expect one row and one key. Related Stripe IDs enrich that row. + +### 3.6 Amount cheat sheet + +| Principal | Expected fee | Customer pays before tax | +|---:|---:|---:| +| $10.00 | $0.50 | $10.50 | +| $20.00 | $1.00 | $21.00 | +| $49.00 | $2.45 | $51.45 | +| $50.00 | $2.50 | $52.50 | +| $100.00 | $5.00 | $105.00 | +| $500.00 | $25.00 | $525.00 | +| $1,000.00 | $50.00 | $1,050.00 | +| $49.00 with 20% unrestricted coupon | $1.96 collected | $41.16 | +| $49.00 with 100% coupon | $0.00 | $0.00 | +| $0.01 | $0.00; omit line | $0.01 | + +`FIRST_TOPUP_BONUS_AMOUNT` is currently `0`. If that changes, credits may +include a bonus; the fee base is still the paid principal, not the bonus. + +### 3.7 Post-journey cleanup checkpoint + +After every completed, failed, or abandoned payment attempt: + +1. Expire every open Checkout Session that will not be paid. +2. Confirm whether Stripe created a payment, invoice, subscription, refund, or + dispute before retrying. Do not infer failure from the browser alone. +3. Verify no assessment remains `pending`: + +```sql +SELECT assessment_key, flow, outcome, stripe_checkout_session_id, + stripe_invoice_id, failure_code, created_at +FROM stripe_service_fee_assessments +WHERE outcome = 'pending' +ORDER BY created_at; +``` + +4. Record abandoned and diagnostic assessments in the journey notes. Do not + delete them or count them as the accepted commercial event. +5. Verify a failed Kilo Pass attempt did not leave an active subscription or + entitlement. If anti-abuse canceled and refunded it, verify both the Stripe + refund and the assessment's refund columns. +6. Before recording a retry, create a clean commercial event. Do not splice a + diagnostic take into the accepted clip. + +Run database-backed Jest suites sequentially during this validation work. +Concurrent suites can collide on shared test-database names and create false +failures. + +--- + +## 4. Journey index + +| ID | Journey | Invariant | +|---|---|---| +| V0 | Fixtures: users, orgs, Stripe promos | Later journeys have clean actors | +| V1 | Personal $100 top-up | Fee $5; credits $100 | +| V2 | Organization $100 top-up | Fee $5; org credits $100 | +| V3 | Personal auto-top-up setup | Initial charge includes fee; credits = principal | +| V4 | Subsequent personal auto-top-up | Off-session invoice has one fee; credits = principal | +| V5 | Organization auto-top-up setup | Same as V3 for the org | +| V6 | Subsequent organization auto-top-up | Same as V4 for the org | +| V7 | Personal Kilo Pass $49 monthly | Recurring product + one-time fee; entitlement unchanged | +| V8 | Personal Kilo Pass + 20% promo | Fee discounted proportionally to $1.96 | +| V9 | Personal Kilo Pass + 100% promo | Charged, zero product, zero fee, not missed | +| V10 | Org Kilo Pass on existing seats | Fee only on Kilo Pass, never seats | +| V11 | Seat increase with org Kilo Pass | Fee only on Kilo Pass proration | +| V12 | Seat-only purchase | No assessment, no fee line | +| V13 | Direct KiloClaw subscribe | N/A — new provisioning deprecated | +| V16 | Admin grants exemption | Reason required; history visible only to admin | +| V17 | Exempt org top-up | No fee line; outcome `exempt`; credits = principal | +| V18 | Member personal top-up while org exempt | Personal fee still charged | +| V19 | Child org top-up while parent exempt | Child is charged | +| V20 | Revoke exemption, then org top-up | New purchase is charged | +| V21 | Non-admin exemption access | Hidden / unauthorized | +| V22 | Customer org surfaces | No exemption fields or copy | +| V23 | Fee-positive top-up email | Credits added, fee, total paid | +| V24 | Fee-free top-up email | Fee row omitted | +| V25 | Billing history vs Stripe invoice | Kilo shows gross; Stripe itemizes fee | +| V26 | Admin revenue dashboard | Existing credit series plus separate settled service-fee metrics | +| V27 | Admin cancel-and-refund Kilo Pass | Full product + fee refunded | +| V28 | Operator partial refund in Stripe | Assessment follows runbook; no auto-correcting refund | +| V29 | Chargeback then win | Dispute columns set then cleared; outcome stays `charged` | +| V30 | Pre-activation purchase | No fee; outcome `pre_activation` | +| V32 | Product-restricted coupon | Effective rate > 5%; deviation recorded; no corrective charge | +| V33 | Fail-open (local fault) | Payment succeeds; outcome `missed`; Slack attempt | + +V13 (new KiloClaw provisioning), V14 (store Kilo Pass), and V15 (manual org +agreement) cannot be performed in this web app. Record them as `N/A` with the +reason. Do not fake them. Existing KiloClaw invoices stay fee-exempt in +classifier tests; do not invent a Checkout. + +Acceptance-criteria map from `GOAL.md`: + +| Goal criterion | Journeys | +|---|---| +| 1 Activation boundary | V30, and any fee-applies journey after the override or after 2026-11-01 | +| 2 Eligible flows | V1–V7, V10 | +| 3 Excluded flows | V12; V13 N/A; V14/V15 N/A | +| 4 Mixed seat + Kilo Pass | V10, V11 | +| 5 Discounts reduce fee; seat discounts do not | V8, V10, V11 | +| 7 Credits use principal | V1–V6, V17 | +| 8 Existing subscriptions after activation | V4, V6, plus a renewal if a test clock is used | +| 9 Exact-org exemption | V16–V22 | +| 10 One assessment across Stripe objects | every payment journey, helper 3.5 | +| 13 Dashboard settled-only | V26 | +| 15 Refunds | V27, V28, V29 | +| 16 Top-up emails | V23, V24 | +| 17 Billing history unchanged | V25 | + +--- + +## 5. Journeys + +Each journey uses the same shape: actor, preconditions, browser steps, UI +expect, database expect, supporting Stripe/email expect, video-evidence clips, +pass rule. Every **Video** block is a required `video-evidence` clip list. +Produce those clips only through that skill. + +### V0 — Fixtures + +**Actor:** operator, then each persona. + +**Steps** + +1. Confirm section 1. +2. Fake-login each persona. Skip the survey. +3. As `P-OWNER`, create `ORG-A` and `ORG-B` at `/organizations/new`. +4. Invite `P-MEMBER` to `ORG-B`. +5. As `P-ADMIN`, open `/admin/organizations`, open `ORG-B`, create `ORG-CHILD`, + and assign `P-CHILD`. +6. Create the three Stripe promotion codes in section 1.3. +7. Audit all configured Kilo Pass Price tax behaviors as described in 1.3.1. +8. Create fresh V7, V8, V9, and V32 users. Assign each a different documented + Stripe test payment method and record the observed fingerprints after use. + +**UI expect:** each persona lands on `/profile` or an organization page. Admin +can open `/admin/organizations`. + +**Database expect:** all nine persona rows listed in section 1.4 and three +organizations. + +**Video:** none. V0 is setup (login, account creation, org seeding, Stripe +promo codes). The `video-evidence` skill forbids recording that work. Note the +IDs and promo codes in `evidence/v0.md` only. + +--- + +### V1 — Personal $100 credit top-up + +**Invariant:** customer pays $105 before tax, receives $100 credits, Stripe +shows `Service fee (5%)` $5.00, one `personal_top_up` assessment settles +charged. + +**Actor:** `P-PERSONAL`. + +**Steps** + +1. Sign in as `P-PERSONAL` with `callbackPath=/credits` **before recording**. +2. Start the `video-evidence` take on `/credits` with Buy Credits visible. +3. Click `$100`. +4. On Stripe Checkout, hold until the credit line and `Service fee (5%)` + `$5.00` are readable, then pay with `4242…`. +5. Wait through `Processing Payment` on `/payments/topup/success`. +6. Land on `/credits?transaction_id=` and hold the success card. + +**UI expect** + +- Checkout shows principal $100.00 and `Service fee (5%)` $5.00. +- Kilo pre-purchase buttons still show `$100`, not `$105`. +- Success card: `$100.00 in credits added`. +- Current balance increases by $100.00, not $105.00. +- `/invoices` shows a paid row whose displayed total is the gross (at least + $105.00 before any tax). The row is not itemized. + +**Database expect** + +| Column | Value | +|---|---| +| `flow` | `personal_top_up` | +| `outcome` | `charged` | +| `eligible_subtotal_minor` | `10000` | +| `expected_fee_minor` | `500` | +| `charged_fee_minor` | `500` | +| `settled_product_minor` | `10000` | +| `gross_paid_minor` | `10500` plus tax if present | +| `organization_id` | `NULL` | +| `kilo_user_id` | `P-PERSONAL` | +| `settled_at` | non-null | + +Matching `credit_transactions.amount_microdollars = 100000000`. + +**Stripe expect:** one fee line, metadata `type=kilo-service-fee`. + +**Email:** V23 may reuse this mail. + +**Video** (`video-evidence`, one clip) + +- label: `V1 personal $100 top-up` +- claim: Checkout shows `$100.00` plus `Service fee (5%)` `$5.00`, and Kilo + then shows `$100.00 in credits added`. +- limits: Does not prove other amounts, org top-ups, emails, or revenue KPI. + +**Pass:** UI credits $100 and DB charged $5. Fail if credits are $105 or Checkout +has no fee line. + +--- + +### V2 — Organization $100 credit top-up + +**Invariant:** same economics as V1, owned by `ORG-A`. + +**Actor:** `P-OWNER`. + +**Steps** + +1. Sign in with `callbackPath=/organizations/`. +2. Click `Buy More Credits` (or open `/organizations//payment-details` + and use the $100 preset). +3. Complete Stripe Checkout. Confirm the fee line before paying. +4. Return to the organization. Balance / toast should show $100, not $105. + +**UI expect:** organization balance +$100. Payment history gross includes the +fee. `View` opens the Stripe invoice with the fee line. + +**Database expect:** `flow = organization_top_up`, `organization_id = ORG-A`, +`kilo_user_id` may be `P-OWNER`, amounts as V1. + +**Video** (`video-evidence`, one clip) + +- label: `V2 organization $100 top-up` +- claim: Org Checkout shows the $5.00 fee, and the organization balance rises + by $100.00. +- limits: Does not prove personal top-ups, exemption, or auto-top-up. + +--- + +### V3 — Personal auto-top-up setup + +**Invariant:** enabling auto-top-up performs an immediate verification charge +of the selected principal plus 5%. Credits equal the principal. + +**Actor:** `P-PERSONAL` after V1 (already has a Stripe customer). + +**Steps** + +1. Open `/profile` or `/credits`. +2. Click `Configure automatic top-up`. +3. Leave the default amount `$50` unless the UI forces another listed amount. +4. Click `Enable automatic top up`. If redirected to Stripe, complete Checkout + and confirm the fee line. +5. Return to `/profile?auto_topup_setup=success` (or equivalent). Reload until + the toggle shows auto-top-up on. + +**UI expect:** auto-top-up enabled. Balance increased by the principal ($50), +not $52.50. Kilo settings still show `$50`, not `$52.50`. + +**Database expect** + +| Column | Value | +|---|---| +| `flow` | `personal_auto_top_up_setup` | +| `outcome` | `charged` | +| `eligible_subtotal_minor` | `5000` | +| `expected_fee_minor` / `charged_fee_minor` | `250` | +| `settled_product_minor` | `5000` | + +`auto_top_up_configs` for the user is enabled with `amount_cents = 5000`. + +**Video** (`video-evidence`, one clip) + +- label: `V3 personal auto-top-up setup` +- claim: Enabling auto-top-up charges principal plus 5%, and the balance rises + by the principal only. +- limits: Does not prove a later off-session auto-top-up. + +--- + +### V4 — Subsequent personal auto-top-up + +**Invariant:** the off-session invoice is Kilo-owned, carries exactly one fee, +and grants principal only. `invoice.created` must not attach a second fee. + +**Actor:** `P-ADMIN`, then `P-PERSONAL`. + +**Preconditions:** V3 passed. Personal balance is above $5. + +**Steps** + +1. As `P-ADMIN`, open `/admin/users`, find `P-PERSONAL`, use + `Grant / Decrement Credits` to reduce purchased balance below $5.00. Use a + reason `service-fee validation drain`. +2. As `P-PERSONAL`, open `/credits`. Loading balance triggers + `maybePerformAutoTopUp`. +3. Wait and reload until purchased credits increase by the configured + principal. + +**UI expect:** balance rises by $50 (or the configured amount), not $52.50. +No second Checkout. No error toast. + +**Database expect:** a new row, `flow = personal_auto_top_up`, `outcome = +charged`, `stripe_invoice_id` set, `stripe_checkout_session_id` null, amounts +matching the configured principal. Exactly one assessment for that invoice. +`charged_fee_minor` equals `expected_fee_minor`. + +**Stripe expect:** invoice has principal item + one `Service fee (5%)` item. +Invoice metadata `type = auto-topup`. + +**Video** (`video-evidence`, two clips; split personas) + +1. label: `V4 admin drain below auto-top-up threshold` + claim: Admin decrements the user's purchased credits below $5.00. + limits: Does not prove the subsequent charge. +2. label: `V4 personal subsequent auto-top-up` + claim: Reloading `/credits` credits the configured principal, not principal + plus fee, with no second Checkout. + limits: Does not prove invoice-line ownership; confirm that in Stripe/SQL. + +**Pass:** one fee, principal credits. Fail if two fee items exist or credits +include the fee. + +--- + +### V5 — Organization auto-top-up setup + +Same shape as V3 on `/organizations//payment-details`. + +Default principal is `$500` (`50000` cents) → fee `$25.00`. + +`flow = organization_auto_top_up_setup`. + +**Video** (`video-evidence`, one clip) + +- label: `V5 organization auto-top-up setup` +- claim: Enabling org auto-top-up charges principal plus 5%, and org balance + rises by the principal only. +- limits: Does not prove a later off-session org auto-top-up. + +--- + +### V6 — Subsequent organization auto-top-up + +Same shape as V4. Admin nullifies `ORG-A` credits from +`/admin/organizations/` (`Confirm Nullification`). As the owner, open +`/organizations//app-builder`. Its organization eligibility check reads +the member's organization balance and schedules the off-session auto-top-up. +A plain reload of the organization details or payment-details page does not +exercise that balance boundary and is not a valid trigger. + +After the invoice settles, reload `/organizations/` or payment-details +to confirm the new balance. + +`flow = organization_auto_top_up`. Invoice metadata `type = org-auto-topup`. +Exactly one fee item. + +**Video** (`video-evidence`, two clips; split personas) + +1. label: `V6 admin nullify org credits` + claim: Admin nullifies `ORG-A` credits. + limits: Does not prove the subsequent charge. +2. label: `V6 organization subsequent auto-top-up` + claim: Opening organization App Builder triggers the configured off-session + top-up, and the organization page then shows the configured principal. + limits: Does not prove invoice-line ownership; confirm that in Stripe/SQL. + +--- + +### V7 — Personal Kilo Pass, $49 monthly + +**Invariant:** Checkout has a recurring Kilo Pass price plus a one-time +`Service fee (5%)` $2.45. Entitlement follows the tier, not $51.45. + +**Actor:** a fresh personal user, or `P-PERSONAL` if they have no Kilo Pass. + +**Preconditions:** the selected recurring Kilo Pass Price has explicit Stripe +`tax_behavior`. The actor's planned payment fingerprint has not been used by +another Kilo Pass validation buyer. + +**Steps** + +1. Open `/subscriptions/kilo-pass` or `/profile`. +2. Select the $49 monthly tier. Click `Buy now`. +3. On Stripe Checkout confirm: Kilo Pass $49.00 recurring, `Service fee (5%)` + $2.45 one-time. Do not enter a promotion code. +4. Pay. Wait on `/payments/kilo-pass/awarding`. +5. Open `/subscriptions/kilo-pass` and confirm the active card. + +**UI expect:** Kilo UI still advertises $49. Active subscription is the $49 +tier. Credit grant / threshold matches the $49 tier, not $51.45. + +**Database expect** + +| Column | Value | +|---|---| +| `flow` | `personal_kilo_pass` | +| `outcome` | `charged` | +| `eligible_subtotal_minor` | `4900` | +| `expected_fee_minor` | `245` | +| `charged_fee_minor` | `245` | +| `settled_product_minor` | `4900` | + +One assessment. Checkout session, subscription invoice, PaymentIntent, and +charge all point at it. + +**Video** (`video-evidence`, one clip) + +- label: `V7 personal Kilo Pass $49` +- claim: Checkout shows recurring $49.00 plus one-time `Service fee (5%)` + $2.45, and Kilo activates the $49 tier. +- limits: Does not prove renewals, discounts, or org Kilo Pass. + +--- + +### V8 — Personal Kilo Pass with unrestricted 20% promo + +**Invariant:** hosted promotion-code entry remains. Product and fee both drop +20%. Collected fee is $1.96. Outcome stays `charged`. No deviation alert. + +**Actor:** a user with no Kilo Pass. Do not reuse V7's user or payment +fingerprint. + +**Preconditions:** verify the planned test payment method has a fingerprint +different from V7 and any prior Kilo Pass purchase. If duplicate-card +protection fires, the discount allocation may still be valid supporting +evidence, but V8 is `BLOCKED` until a clean attempt retains the active `$49` +tier entitlement. + +**Steps** + +1. Repeat V7 through Stripe Checkout. +2. Enter `VALIDATE20`. Apply. +3. Confirm Kilo Pass $39.20, fee $1.96, total $41.16 before tax. +4. Pay and wait for the active subscription. + +**UI expect:** Checkout shows the discounted fee. Kilo still grants the $49 +tier entitlement, not a discounted entitlement. + +**Database expect:** `expected_fee_minor = 245` (list). `charged_fee_minor = +196`. `settled_product_minor = 3920`. `outcome = charged`. No +`service_fee_rate_deviation` in `metadata` / `failure_code`. + +**Video** (`video-evidence`, one clip) + +- label: `V8 personal Kilo Pass 20% promo` +- claim: Applying `VALIDATE20` shows Kilo Pass $39.20 and fee $1.96 before + pay, then the $49 tier activates. +- limits: Does not prove restricted coupons or 100% off. + +**Fail if:** fee stays $2.45 after the code, or entitlement is reduced, or the +assessment is `missed`. + +--- + +### V9 — Personal Kilo Pass with 100% promo + +**Actor:** a fresh user with no Kilo Pass. Do not reuse a V7/V8 user or payment +fingerprint. If Stripe does not collect a payment method for the zero-dollar +subscription, record that provider behavior rather than trying to manufacture +a fingerprint. + +**Steps:** same as V8 with `VALIDATE100`. Both lines become $0.00. + +**Database expect:** `outcome = charged`, `charged_fee_minor = 0`, +`settled_product_minor = 0`. This is not `missed` and not `zero_rounded`. + +**UI expect:** subscription still activates if a 100% coupon is a valid +purchase in Stripe test mode. If Stripe refuses a $0 subscription, record the +provider behavior in `evidence/v9.md` and stop; do not invent a workaround. + +**Video** (`video-evidence`, one clip) + +- label: `V9 personal Kilo Pass 100% promo` +- claim: Applying `VALIDATE100` zeroes product and fee lines, and the + assessment is not presented as a failed payment. +- limits: Does not prove Stripe will always accept a $0 subscription. + +--- + +### V10 — Organization Kilo Pass on existing seats + +**Invariant:** mixed invoice. Fee base is Kilo Pass only. + +**Preconditions:** `ORG-A` has a paid Teams/Enterprise seat subscription from +V12, or buy seats first without asserting V12 if V12 is run later. Prefer +running V12 immediately before this journey on `ORG-A` if it has no seats. + +**Actor:** `P-OWNER`. + +**Steps** + +1. Open `/organizations//subscriptions`. +2. Click `Add Kilo Pass`. +3. On `/organizations//subscriptions/kilo-pass/setup` choose the lowest + tier, keep current paid seats, click `Purchase Kilo Pass`. +4. Complete Stripe / `handleNextAction` if shown. +5. Land on `/organizations//subscriptions/kilo-pass`. +6. Open the Stripe invoice from org billing history `View`. + +**UI expect:** Kilo Pass is active. Invoice total is seats (if present on the +same invoice) + Kilo Pass + 5% of Kilo Pass only. Hosted invoice contains +exactly one `Service fee (5%)` line. That line equals 5% of the Kilo Pass +amount, not 5% of seats. + +**Database expect:** `flow = organization_kilo_pass`, `charged_fee_minor = +round_half_up(kilo_pass_subtotal * 5%)`, `settled_product_minor` equals the +Kilo Pass net, not seats. + +**Video** (`video-evidence`, two clips if the hosted invoice opens a new +context) + +1. label: `V10 org Kilo Pass purchase` + claim: Purchasing org Kilo Pass succeeds and the product is active. + limits: Does not by itself prove the fee base excluded seats. +2. label: `V10 org Kilo Pass hosted invoice` + claim: The hosted invoice has exactly one `Service fee (5%)` line equal to + 5% of Kilo Pass, not of seats. + limits: Does not prove later capacity changes. + +--- + +### V11 — Seat increase while organization Kilo Pass is attached + +**Invariant:** increasing seats prorates both products. Fee applies only to +the Kilo Pass proration. + +**Actor:** `P-OWNER` on `ORG-A` after V10. + +**Steps** + +1. Open `/organizations//subscriptions/seats`. +2. Increase paid seats by 1. Confirm. Complete SCA if asked. +3. Open the new invoice. + +**UI expect:** success toast `Seats updated successfully!`. Hosted invoice fee +equals 5% of the Kilo Pass proration line(s), not the seat proration. + +**Database expect:** new `organization_kilo_pass` assessment. +`settled_product_minor` matches Kilo Pass proration only. Seat-only negative +control: if the Kilo Pass proration nets to less than $0.10, outcome may be +`zero_rounded` and there is no fee line. + +**Video** (`video-evidence`, one or two clips if the invoice is a separate +page) + +- label: `V11 org seat increase with Kilo Pass` +- claim: Increasing seats succeeds, and the new invoice fee equals 5% of the + Kilo Pass proration only. +- limits: Does not prove a seat-only subscription. + +--- + +### V12 — Seat-only purchase (excluded) + +**Actor:** `P-OWNER` on an org with no Kilo Pass. Use `ORG-B` if `ORG-A` already +has Kilo Pass, or run this before V10 on `ORG-A`. + +**Steps** + +1. Open `/organizations//subscriptions`. +2. Choose Teams, monthly, 1 seat. Click `Purchase Teams Plan`. +3. Complete Stripe Checkout. +4. Confirm seats are active. + +**UI expect:** Checkout has seat lines only. No `Service fee (5%)`. + +**Database expect:** no `stripe_service_fee_assessments` row for the Checkout +session, invoice, PaymentIntent, or charge. + +**Video** (`video-evidence`, one clip) + +- label: `V12 seat-only purchase` +- claim: Teams seat Checkout has no `Service fee (5%)` line and seats become + active. +- limits: Does not prove mixed seat + Kilo Pass invoices. + +--- + +### V13 — Direct KiloClaw subscribe (excluded) — N/A + +New KiloClaw instance provisioning is deprecated. `/claw` and +`/subscriptions#kiloclaw` tell a fresh user that new instances are unavailable +and that a current subscription is required. There is no PlanSelectionDialog or +Stripe Checkout to inspect. + +Record `N/A`. Do not create a leftover instance or point the local stack at a +KiloClaw API to force a Checkout. Classifier tests remain the proof that a +KiloClaw invoice line is not fee-bearing. + +--- + +### V16 — Admin grants an exemption + +**Actor:** `P-ADMIN`. Target: `ORG-B`. + +**Steps** + +1. Open `/admin/organizations`, search `Fee Validate B`, open the record. +2. Find the service-fee card near billing controls. Current state `Fees apply`. +3. Click `Grant exemption`. +4. In the dialog, leave reason blank and try to confirm. Expect validation. +5. Enter reason `Validation grant: contracted prepaid 2026`. Confirm. +6. Confirm current state `Exempt`. History shows the grant, actor, reason, and + a local-time timestamp. + +**UI expect:** reason is required (min 3, max 500). Controls disable while +saving. Escape closes the dialog without saving when cancelled. Layout works +at 375 px; long reasons wrap. + +**Database expect** + +```sql +SELECT id, is_exempt, reason, changed_by_kilo_user_id, created_at +FROM organization_service_fee_exemptions +WHERE organization_id = '' +ORDER BY created_at DESC, id DESC; +``` + +Newest row has `is_exempt = true` and a non-empty reason. No new row in +`organization_audit_logs` exists for this action. + +**Video** (`video-evidence`, one clip) + +- label: `V16 grant organization exemption` +- claim: An admin must enter a reason, then the org card shows `Exempt` and + history lists the grant. +- limits: Does not prove a later purchase is fee-free. + +--- + +### V17 — Exempt organization top-up + +**Actor:** `P-OWNER` on `ORG-B` after V16. + +**Steps:** repeat V2 against `ORG-B`. + +**UI expect:** Checkout has **no** `Service fee (5%)` line. Total is $100 +before tax. Org balance +$100. + +**Database expect:** `flow = organization_top_up`, `outcome = exempt`, +`expected_fee_minor = 500`, `charged_fee_minor = 0`, `exemption_id` = the V16 +exemption row, `settled_at` non-null after +payment. Credits $100. + +**Email:** reuse for V24. + +**Video** (`video-evidence`, one clip) + +- label: `V17 exempt organization top-up` +- claim: Exempt org Checkout has no fee line, and org balance rises by $100. +- limits: Does not prove personal or child-org purchases. + +--- + +### V18 — Member personal purchase while org is exempt + +**Actor:** `P-MEMBER`, who belongs to exempt `ORG-B`. + +**Steps:** personal $20 top-up from `/credits`. Do not use org purchase. + +**UI expect:** Checkout fee $1.00. Personal balance +$20. + +**Database expect:** `flow = personal_top_up`, `organization_id IS NULL`, +`outcome = charged`, `charged_fee_minor = 100`. No exemption link. + +**Video** (`video-evidence`, one clip) + +- label: `V18 member personal top-up while org exempt` +- claim: The member's personal Checkout still shows a 5% fee. +- limits: Does not prove org billing for that member. + +--- + +### V19 — Child organization does not inherit exemption + +**Actor:** `P-CHILD` on `ORG-CHILD`. Parent `ORG-B` is exempt. + +**Steps:** $100 org top-up for `ORG-CHILD`. + +**UI expect:** fee $5.00 present. + +**Database expect:** `organization_id = ORG-CHILD`, `outcome = charged`, +`exemption_id IS NULL`. + +**Video** (`video-evidence`, one clip) + +- label: `V19 child org top-up while parent exempt` +- claim: Child-org Checkout shows a $5.00 fee. +- limits: Does not prove parent exemption state beyond this purchase. + +--- + +### V20 — Revoke exemption, then charge + +**Actor:** `P-ADMIN`, then `P-OWNER`. + +**Steps** + +1. Admin opens `ORG-B`, clicks `Revoke exemption`, reason + `Validation revoke: contract ended`. +2. History shows grant then revoke. Current state `Fees apply`. +3. Owner buys another $100 org top-up for `ORG-B`. + +**UI expect:** this Checkout has the $5.00 fee. The earlier V17 invoice is +unchanged. + +**Database expect:** second assessment `outcome = charged`, `charged_fee_minor += 500`. V17 row remains `exempt`. The exemption log has two rows and its +newest row is the revoke row with `is_exempt = false`. + +**Video** (`video-evidence`, two clips; split personas) + +1. label: `V20 revoke organization exemption` + claim: Admin revokes with a reason; current state is `Fees apply`. + limits: Does not prove the next purchase. +2. label: `V20 org top-up after revoke` + claim: The next `ORG-B` Checkout shows a $5.00 fee. + limits: Does not prove the earlier V17 invoice changed. + +--- + +### V21 — Non-admin cannot manage exemptions + +**Actor:** `P-OWNER`. + +**Steps** + +1. Open `/admin/organizations/`. Expect redirect to + `/admin/unauthorized` or no admin shell. +2. Open `/organizations/` and `/organizations//payment-details`. + +**UI expect:** no `Grant exemption`, no `Exempt` / `Fees apply` service-fee +card, no exemption history. + +**Database expect:** no new exemption-log rows from this actor. + +**Video** (`video-evidence`, one clip) + +- label: `V21 non-admin exemption hidden` +- claim: A non-admin cannot open the admin exemption controls or see exemption + copy on customer org pages. +- limits: Does not prove API payload absence; that is the supporting network + note in V22. + +--- + +### V22 — Customer organization APIs do not expose exemption + +**Actor:** `P-OWNER` on `ORG-B` (exempt or not). + +**Steps** + +1. After login, start the `video-evidence` take on `/organizations/`. +2. Open org settings, billing, members, and audit log pages. +3. Search the pages for `exempt`, `service fee exemption`, and the V16 reason. + +**UI expect:** no matches. + +**Supporting check (not a substitute for the browser):** in DevTools network, +org tRPC responses must not contain exemption fields. Note the request names +in `evidence/v22.md`. Do not record DevTools. + +**Video** (`video-evidence`, one clip) + +- label: `V22 customer org surfaces hide exemption` +- claim: Customer org settings, billing, members, and audit log show no + exemption copy. +- limits: Does not prove tRPC payload shape; that is the written network note. + +--- + +### V23 — Fee-positive top-up email + +**Actor:** operator, using the V1 or V2 mail. + +**Steps** + +1. Open the newest `dev/logs/emails/*.html` for that purchase in the browser + **before recording**. +2. Start the `video-evidence` take on that tab and hold the itemized rows. + +**UI expect:** rows `Credits added` $100.00, `Service fee (5%)` $5.00, and +`Total paid` $105.00 (plus tax if present). + +**Video** (`video-evidence`, one clip) + +- label: `V23 fee-positive top-up email` +- claim: The captured top-up email itemizes `Credits added`, `Service fee (5%)`, + and `Total paid`. +- limits: Does not prove provider delivery; local capture only. + +--- + +### V24 — Fee-free top-up email + +Use the V17 mail. + +**UI expect:** principal and credits, no `Service fee (5%)` row, no +"exempt" / "waived" / "error" explanation. + +**Video** (`video-evidence`, one clip) + +- label: `V24 fee-free top-up email` +- claim: The exempt top-up email has no `Service fee (5%)` row and does not + explain why. +- limits: Does not prove other fee-free reasons (pre-activation, missed). + +--- + +### V25 — Billing history stays coarse; Stripe itemizes + +**Actor:** `P-PERSONAL` after V1, and `P-OWNER` after V2. + +**Steps** + +1. Open `/invoices` (personal) and org `Payment History`. +2. Confirm Kilo shows a single gross amount and status. No fee breakdown. +3. Click `View`. On the Stripe hosted invoice, confirm the fee line. +4. Optionally click `PDF` and confirm the same line. + +**Video** (`video-evidence`, two clips if Stripe hosted invoice is a separate +context) + +1. label: `V25 Kilo billing history is gross only` + claim: `/invoices` or org payment history shows a single gross amount with + no fee breakdown. + limits: Does not prove Stripe line items. +2. label: `V25 Stripe hosted invoice itemizes the fee` + claim: `View` opens a Stripe invoice that includes `Service fee (5%)`. + limits: Does not prove PDF contents unless the PDF is opened in this clip. + +--- + +### V26 — Admin revenue dashboard + +**Actor:** `P-ADMIN` after V1, V2, V17, and ideally V8. + +**Steps** + +1. Open `/admin/revenue`. +2. Set the range to include today. If the dashboard excludes today by default + ("ends yesterday"), use Custom and include the validation day. +3. Read the existing credit-revenue figures and the separate collected, + missed, exempted, and disputed service-fee figures and counts. +4. Export CSV if the button exists. + +**UI expect** + +- Existing paid/free/multiplied/unmultiplied credit figures keep their old semantics. +- Assessment-backed V1/V2 top-ups remain in paid credit totals and contribute $5.00 each to the separate collected-fee total. +- V17 contributes to exempted fee ($5.00) and not to collected fee. +- No product or gross assessment revenue is shown; Kilo Pass product revenue remains outside this dashboard. +- Abandoned / unpaid Checkouts from a cancelled attempt do not appear in fee metrics. +- Empty-state does not crash if you pick a date range with no data. Check that + by switching to a future-empty custom range, then switch back. + +**Database expect:** dashboard figures match + +```sql +SELECT + sum(greatest(charged_fee_minor - refunded_fee_minor - disputed_fee_minor, 0)) + AS collected_fee_minor, + sum(CASE WHEN outcome = 'missed' THEN expected_fee_minor ELSE 0 END) + AS missed_fee_minor, + sum(CASE WHEN outcome = 'exempt' THEN expected_fee_minor ELSE 0 END) + AS exempt_fee_minor, + sum(disputed_fee_minor) AS disputed_fee_minor, + count(*) FILTER (WHERE outcome = 'charged') AS charged_count +FROM stripe_service_fee_assessments +WHERE settled_at IS NOT NULL + AND (settled_at AT TIME ZONE 'UTC')::date = CURRENT_DATE; +``` + +Unpaid rows must not be in those sums. + +**Video** (`video-evidence`, one clip) + +- label: `V26 admin revenue dashboard` +- claim: The dashboard preserves the existing credit series, shows separate + fee collection and leakage values for the validation day, and an empty range does not crash. +- limits: Does not prove SQL arithmetic beyond what is on screen. + +--- + +### V27 — Admin full cancel-and-refund of Personal Kilo Pass + +**Invariant:** existing Kilo-initiated full refund returns product and fee. +No new partial-refund UI is added. + +**Actor:** `P-ADMIN`. Target: the V7 user (undiscounted $49 + $2.45). + +**Steps** + +1. Open `/admin/users/`. +2. On the Kilo Pass card, cancel and refund with a reason + `service-fee validation full refund`. +3. Confirm the toast includes a refund amount of $51.45 (plus tax if charged). +4. As the customer, reload `/subscriptions/kilo-pass` and `/invoices`. + +**UI expect:** subscription cancelled / blocked per existing admin behavior. +Stripe invoice or receipt shows product and fee refunded. + +**Database expect:** same assessment, `outcome` remains `charged`, +`refunded_product_minor = 4900`, `refunded_fee_minor = 245`. Refund columns +never decrease after this. + +**Video** (`video-evidence`, two clips; split personas) + +1. label: `V27 admin cancel and refund Kilo Pass` + claim: Admin cancel-and-refund reports about $51.45 (plus tax if charged). + limits: Does not prove the customer UI. +2. label: `V27 customer Kilo Pass after full refund` + claim: The customer subscription/invoice state reflects the cancellation + and refund. + limits: Does not prove partial refunds. + +--- + +### V28 — Operator partial refund in Stripe + +**Invariant:** this product does not add a partial-refund Admin UI. An +operator refunds in Stripe. The assessment records what can be allocated and +does not silently issue a second refund. + +**Actor:** operator in Stripe Dashboard / CLI, then `P-ADMIN` / customer. + +**Preconditions:** a settled V2 charge that has not been refunded. + +**Steps** + +1. In Stripe test mode, refund **$20.00** of the V2 charge without a credit + note that allocates lines, unless the runbook says otherwise. +2. Wait for `charge.refunded`. +3. Reload org payment history and `/admin/revenue`. + +**Database expect:** follow the operator runbook in `kilo-org/on-call`. If the +refund cannot be allocated to product vs fee, `refund_allocation_unresolved` +is recorded, `refunded_fee_minor` is unchanged, and Stripe shows only the +$20.00 refund the operator created — no second automatic refund. + +If the operator instead issues a credit note that refunds $20.00 product and +$1.00 fee, the assessment should show `refunded_product_minor = 2000` and +`refunded_fee_minor = 100`. + +**Video** (`video-evidence`, two clips; split Stripe Dashboard vs Kilo) + +1. label: `V28 operator partial refund in Stripe` + claim: The operator issues the documented partial refund. + limits: Does not prove Kilo assessment state. +2. label: `V28 Kilo after partial refund` + claim: Org history and revenue reload without a second automatic refund. + limits: Does not prove every runbook allocation case. + +--- + +### V29 — Chargeback then win + +**Actor:** HITL operator in Stripe Dashboard (test mode), then admin in Kilo. +The Stripe CLI cannot attach a dispute to an existing charge. Do not use +`stripe trigger charge.dispute.created` — that creates a new unrelated charge. + +**Preconditions:** a settled fee-positive assessment that has not been refunded +or disputed. Prefer a dedicated `$100` personal top-up if V1's user was later +blocked (V27 Nuke Pass). Record the charge id, assessment key, and whether +`/credits` is reachable. + +**Steps** + +1. In Stripe Dashboard test mode, open the target Payment and create a test + dispute. Advance it until `charge.dispute.funds_withdrawn` is forwarded. +2. Reload `/credits` if the user is not blocked, and `/admin/revenue` with a + Custom range that includes the settlement day. +3. Close the dispute in Kilo's favor (Dashboard win / submit evidence so + status becomes `won` and `charge.dispute.closed` fires). +4. Reload revenue. + +**UI expect:** customer credits are not silently increased by the fee. Revenue +collected-fee drops after withdrawal and returns after the win. Existing credit-revenue figures do not change because of the dispute. + +**Database expect:** after withdrawal, `disputed_fee_minor = 500`, `outcome = charged`, and refund columns are unchanged. After a win, `disputed_fee_minor = 0`. + +**Video** (`video-evidence`, two or three clips; do not record Stripe helper +setup in the Kilo clip) + +1. label: `V29 revenue after funds withdrawn` + claim: After the dispute withdrawal, collected fee on `/admin/revenue` + drops for the V1 payment. + limits: Does not prove the Stripe dispute UI. +2. label: `V29 revenue after dispute won` + claim: After a win, collected fee is restored and the assessment remains + charged. + limits: Does not prove refund-column monotonicity; that is SQL. + +--- + +### V30 — Pre-activation purchase + +**Invariant:** a billing object created before `2026-11-01T00:00:00Z` is +fee-free even if it pays later. + +**How to run** + +- If wall-clock is before activation: **remove** any local activation + override, then repeat V1. +- If wall-clock is after activation: do not fake this in production data. + Create one Checkout with a local override of the constant to the future, + or keep a pre-recorded pre-activation fixture. Document which method was + used. + +**UI expect:** Checkout has no fee line. Credits equal the button amount. + +**Database expect:** `outcome = pre_activation`, `expected_fee_minor = 500` +for a $100 top-up, +`charged_fee_minor = 0`, `settled_at` set after pay. + +**Video** (`video-evidence`, one clip) + +- label: `V30 pre-activation personal top-up` +- claim: Checkout has no fee line, and Kilo credits the button amount. +- limits: Does not prove post-activation behavior. + +--- + +### V32 — Product-restricted coupon (operational defect signature) + +**Invariant:** a coupon that discounts only Kilo Pass overcharges relative to +the published 5%. The system does not "fix" the customer. It settles observed +amounts and alerts. + +**Actor:** a fresh user. Operator created `VALIDATEKPONLY`. Do not reuse a +V7/V8/V9 user or payment fingerprint. + +**Steps** + +1. Start Personal Kilo Pass Checkout as in V7. +2. Apply `VALIDATEKPONLY`. +3. Confirm product $39.20 and fee still $2.45 (effective 6.25%). +4. Pay. + +**UI expect:** Checkout shows the uneven discount. Kilo grants the normal $49 +tier. No extra charge or refund appears later. + +**Database expect:** `charged_fee_minor = 245`, `settled_product_minor = 3920`, +`outcome = charged`. Deviation recorded (`service_fee_rate_deviation` in +metadata or the agreed column). No second assessment and no automatic refund. + +**Video** (`video-evidence`, one clip) + +- label: `V32 product-restricted coupon` +- claim: `VALIDATEKPONLY` discounts Kilo Pass and leaves the fee at $2.45; + the $49 tier still activates with no later corrective charge. +- limits: Does not prove the Slack deviation alert; note that in SQL/logs. + +Delete `VALIDATEKPONLY` after the take. + +--- + +### V33 — Fail open (local fault) + +**Invariant:** a fee-domain failure must not block payment. + +This is the one journey that may use a local uncommitted fault. Preferred +fault: make `tax.ts` throw during tax-behavior resolution, or point the Price retrieve at an +invalid price, then create a top-up. + +**Steps** + +1. Introduce the fault. Restart web if needed. +2. Repeat V1. +3. Remove the fault immediately after the take. + +**UI expect:** Checkout has **no** fee line. Payment succeeds. Credits $100. + +**Database expect:** `outcome = missed`, `expected_fee_minor = 500`, +`charged_fee_minor = 0`, `failure_code = fee_application_failed`, +`settled_at` set after pay. + +**Supporting:** Admin Slack attempt is logged. Slack failure, if simulated, +still leaves the payment successful. + +**Video** (`video-evidence`, one clip; do not record injecting or reverting +the local fault) + +- label: `V33 fail-open top-up` +- claim: With the fee fault in place, Checkout has no fee line, payment + succeeds, and Kilo credits $100. +- limits: Does not prove Slack delivery or the failure code; those are SQL + and logs. + +If a safe local fault cannot be introduced without risking other journeys, +mark V33 `BLOCKED` and rely on the SPEC webhook tests. Do not skip the note. + +--- + +## 6. Hybrid renewal (optional, recommended before activation) + +Existing Personal and org Kilo Pass subscriptions must pick up the fee on the +first invoice created after activation. That invoice is Stripe-owned. + +**Mechanism:** Stripe test clock on the V7 or V10 subscription, advance to +renewal, then: + +1. Customer opens `/invoices` or org billing history. +2. `View` the renewal invoice. +3. Confirm one `Service fee (5%)` line, `discountable = false` on the invoice + item, amount = 5% of that invoice's Kilo Pass net. + +**Database:** new assessment, `assessment_key = invoice:`, not a second +`checkout:*` row. `charged_fee_minor = expected_fee_minor`. + +**Video** (`video-evidence`, one clip; do not record advancing the test clock) + +- label: `V-renewal hosted invoice` +- claim: The post-activation renewal invoice shows one `Service fee (5%)` + line equal to 5% of that invoice's Kilo Pass net. +- limits: Does not prove test-clock setup or pre-activation invoices. + +If test clocks are unavailable, schedule this as the first production +activation check in `SPEC.md` Phase 6 rather than inventing a clock in app +code. + +--- + +## 7. Suggested run order + +Execute the run in this order so later journeys can reuse state. Produce each +journey's `video-evidence` clips only after that journey's dry-run succeeds: + +1. V0 fixtures, including the Kilo Pass Price tax audit and distinct Kilo Pass + payment-method plan +2. V30 if wall-clock is before activation and the real constant is still set +3. Apply the local activation override if needed and restart the web process +4. V1, V23, V25 (personal path) +5. V3, V4 +6. V2, V5, V6 +7. V12 then V10 then V11 on `ORG-A` +8. V7, V27 +9. V8, V9, V32 on fresh users +10. V16, V17, V24, V18, V19, V20, V21, V22 on `ORG-B` / child / member +11. V13 is N/A (deprecated new KiloClaw provisioning); record the reason +12. V26 +13. V28, then V29 with HITL Dashboard access +14. V33 last, because it mutates local code +15. Expire abandoned Checkouts and confirm no assessment remains `pending` +16. Revert any activation override, local Price-ID override, and V33 fault; + restart or stop the web process so it cannot retain a local mutation +17. Archive disposable Stripe fixtures when no remaining journey needs them +18. Enter and independently verify approved historical exemptions +19. Run the release-environment read-only Kilo Pass classification and + restricted-coupon audits; all findings must be cleared +20. Optional renewal test clock + +Do not parallelize journeys that share an organization or Stripe customer. +Run DB-backed test suites sequentially. + +--- + +## 8. Run log template + +Copy to `.plans/service-fees/evidence/RUN.md` at the start of a validation +run. + +```markdown +# Service fee validation run + +- Date: +- Operator: +- Web base URL: +- Postgres (host/port only, no password): +- Stripe mode: test +- Activation constant used for fee-applies journeys: +- Original activation constant: +- Activation override restored and web process restarted/stopped: +- Activation override committed? no +- Configured Kilo Pass Price tax audit: +- Disposable Price IDs and local overrides, if any: +- Abandoned Checkout Sessions expired: +- Pending assessment count at pause/handoff: +- Historical exemptions entered and independently verified: +- Kilo Pass classification audit environment/time/result: +- Restricted-coupon audit environment/time/result: + +## Personas +| Persona | Email | User id | +| --- | --- | --- | +| P-ADMIN | | | +| P-OWNER | | | +| P-MEMBER | | | +| P-CHILD | | | +| P-PERSONAL | | | +| P-V7 | | | +| P-V8 | | | +| P-V9 | | | +| P-V32 | | | + +## Kilo Pass test payment methods +| Journey | Non-secret test method label | Observed card fingerprint | +| --- | --- | --- | +| V7 | | | +| V8 | | | +| V9 | | | +| V32 | | | + +## Organizations +| Org | Name | UUID | +| --- | --- | --- | +| ORG-A | | | +| ORG-B | | | +| ORG-CHILD | | | + +## Video workspace +- video-evidence workdir (outside repo): +- repository_state: not committed + +## Results +| ID | Result | video-evidence labels | Assessment key | Notes | +| --- | --- | --- | --- | --- | +| V0 | | | | | +| V1 | | | | | +| V2 | | | | | +| V3 | | | | | +| V4 | | | | | +| V5 | | | | | +| V6 | | | | | +| V7 | | | | | +| V8 | | | | | +| V9 | | | | | +| V10 | | | | | +| V11 | | | | | +| V12 | | | | | +| V13 | | | | | +| V16 | | | | | +| V17 | | | | | +| V18 | | | | | +| V19 | | | | | +| V20 | | | | | +| V21 | | | | | +| V22 | | | | | +| V23 | | | | | +| V24 | | | | | +| V25 | | | | | +| V26 | | | | | +| V27 | | | | | +| V28 | | | | | +| V29 | | | | | +| V30 | | | | | +| V32 | | | | | +| V33 | | | | | +``` + +A validation run is complete when every row is `PASS`, `N/A` with reason, or +`BLOCKED` with a SPEC-test pointer. Any `FAIL` blocks calling the feature +done, even if automated suites are green. + +--- + +## 9. Implementation notes for later + +When the feature exists, drive the browser with `agent-browser` and produce +every required clip by loading and following the `video-evidence` skill. Do +not use Playwright for these journeys, and do not submit a raw WebM or an +in-repo recording as evidence. Current `apps/web/tests/e2e` coverage is +visual/accessibility and does not complete Stripe Checkout. + +If a user-visible label in this file is wrong after UI implementation, change +this file. Do not weaken a database assertion to match a product bug. + +Do not add a runtime fee kill switch to make validation easier. The only +allowed local mutations are the uncommitted activation override, a documented +ignored local Kilo Pass Price-ID replacement for explicit-tax test fixtures, +and the V33 fault. Revert all of them before commit or handoff. Restart the web +process after reverting; if restart fails, stop it and record that state rather +than leaving a process with stale validation configuration. 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/app/admin/api/organizations/hooks.ts b/apps/web/src/app/admin/api/organizations/hooks.ts index d8ef9ef136..4daa5e535f 100644 --- a/apps/web/src/app/admin/api/organizations/hooks.ts +++ b/apps/web/src/app/admin/api/organizations/hooks.ts @@ -152,6 +152,36 @@ export function useAdminOrganizationKiloPassSummary(organizationId: string) { ); } +export function useAdminOrganizationServiceFeeExemption(organizationId: string) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.admin.getServiceFeeExemption.queryOptions({ + organizationId, + }) + ); +} + +export function useSetOrganizationServiceFeeExemption() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.organizations.admin.setServiceFeeExemption.mutationOptions({ + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.admin.getServiceFeeExemption.queryKey({ + organizationId: variables.organizationId, + }), + }); + void queryClient.invalidateQueries({ + queryKey: trpc.organizations.admin.getDetails.queryKey({ + organizationId: variables.organizationId, + }), + }); + }, + }) + ); +} + export function useAdminOrganizationHierarchy(organizationId: string, enabled: boolean) { const trpc = useTRPC(); return useQuery( diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx index abb0cba396..8db527e84e 100644 --- a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx +++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx @@ -8,6 +8,7 @@ import { OrganizationAdminCreditTransactions } from './OrganizationAdminCreditTr import { OrganizationAdminDelete } from './OrganizationAdminDelete'; import { OrganizationAdminCreditGrant } from './OrganizationAdminCreditGrant'; import { OrganizationAdminCreditNullify } from './OrganizationAdminCreditNullify'; +import { OrganizationAdminServiceFeeExemption } from './OrganizationAdminServiceFeeExemption'; import { OrganizationAdminCreatedBy } from './OrganizationAdminCreatedBy'; import { OrganizationAdminHierarchyManagement } from './OrganizationAdminHierarchyManagement'; import { OrganizationAdminKiloPass } from './OrganizationAdminKiloPass'; @@ -66,6 +67,7 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId: +
diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts new file mode 100644 index 0000000000..feab451bcc --- /dev/null +++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from '@jest/globals'; +import { + canSubmitServiceFeeExemption, + resolveServiceFeeExemptionDialogOpenChange, + SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH, + SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH, + shouldBlockServiceFeeExemptionDialogDismiss, +} from './OrganizationAdminServiceFeeExemption.dialog-state'; + +describe('resolveServiceFeeExemptionDialogOpenChange', () => { + it('ignores close requests while the mutation is pending', () => { + expect( + resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: false, isMutationPending: true }) + ).toBeNull(); + }); + + it('ignores reopen requests while the mutation is pending so state is never reset mid-flight', () => { + expect( + resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: true }) + ).toBeNull(); + }); + + it('resets the mutation only when the dialog opens while idle', () => { + expect( + resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: false }) + ).toEqual({ open: true, resetMutation: true }); + }); + + it('closes without resetting the mutation while idle', () => { + expect( + resolveServiceFeeExemptionDialogOpenChange({ + requestedOpen: false, + isMutationPending: false, + }) + ).toEqual({ open: false, resetMutation: false }); + }); +}); + +describe('shouldBlockServiceFeeExemptionDialogDismiss', () => { + it('blocks Escape, overlay pointer-down, and outside interaction only while pending', () => { + expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: true })).toBe(true); + expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: false })).toBe(false); + }); +}); + +describe('canSubmitServiceFeeExemption', () => { + it('rejects a pending mutation even with a valid reason to prevent duplicates', () => { + expect( + canSubmitServiceFeeExemption({ + trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH, + isMutationPending: true, + }) + ).toBe(false); + }); + + it('enforces the trimmed reason length bounds while idle', () => { + const idle = { isMutationPending: false }; + expect( + canSubmitServiceFeeExemption({ + trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH - 1, + ...idle, + }) + ).toBe(false); + expect( + canSubmitServiceFeeExemption({ + trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH, + ...idle, + }) + ).toBe(true); + expect( + canSubmitServiceFeeExemption({ + trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH, + ...idle, + }) + ).toBe(true); + expect( + canSubmitServiceFeeExemption({ + trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH + 1, + ...idle, + }) + ).toBe(false); + }); +}); diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts new file mode 100644 index 0000000000..f18ffce1c5 --- /dev/null +++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts @@ -0,0 +1,65 @@ +/** + * Pure dialog-state rules for OrganizationAdminServiceFeeExemption, extracted + * so the pending-mutation dismiss guards are testable without a DOM (the repo + * has no component-test runner). + */ + +// Mirrors ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_* in +// @/lib/service-fees/organization-exemptions, which is server-only and cannot +// be imported from a client component. The router remains the enforcement +// boundary; these only drive client-side enablement and hints. +export const SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH = 3; +export const SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH = 500; + +export type ServiceFeeExemptionDialogOpenChange = { + open: boolean; + resetMutation: boolean; +}; + +/** + * Radix fires onOpenChange for the trigger, Cancel, the close button, Escape, + * and overlay pointer-down. While the set-exemption mutation is in flight, + * every open/close request must be ignored: closing would discard the pending + * UI, and a close-then-reopen would reset the mutation state, clear the + * isPending guard, and allow a duplicate mutation. + * + * Returns null when the request must be ignored, otherwise the next dialog + * state. The mutation is reset only on a fresh open so a previous error does + * not leak into the next attempt. + */ +export function resolveServiceFeeExemptionDialogOpenChange(input: { + requestedOpen: boolean; + isMutationPending: boolean; +}): ServiceFeeExemptionDialogOpenChange | null { + if (input.isMutationPending) return null; + return { open: input.requestedOpen, resetMutation: input.requestedOpen }; +} + +/** + * Guarding onOpenChange alone is not enough for a controlled dialog: Radix + * processes Escape and overlay pointer-down in its own handlers before asking + * React, so DialogContent must also preventDefault those events while the + * mutation is pending. This predicate drives all three content-level guards + * (onEscapeKeyDown, onPointerDownOutside, onInteractOutside). + */ +export function shouldBlockServiceFeeExemptionDialogDismiss(input: { + isMutationPending: boolean; +}): boolean { + return input.isMutationPending; +} + +/** + * Confirm stays inert until the trimmed reason is within the allowed length + * and no mutation is in flight, so double-clicks or repeated Enter presses + * cannot fire a duplicate mutation. + */ +export function canSubmitServiceFeeExemption(input: { + trimmedReasonLength: number; + isMutationPending: boolean; +}): boolean { + return ( + !input.isMutationPending && + input.trimmedReasonLength >= SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH && + input.trimmedReasonLength <= SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH + ); +} diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx new file mode 100644 index 0000000000..fce265cc2f --- /dev/null +++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Textarea } from '@/components/ui/textarea'; +import { + useAdminOrganizationServiceFeeExemption, + useSetOrganizationServiceFeeExemption, +} from '@/app/admin/api/organizations/hooks'; +import { Receipt } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + canSubmitServiceFeeExemption, + resolveServiceFeeExemptionDialogOpenChange, + SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH, + SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH, + shouldBlockServiceFeeExemptionDialogDismiss, +} from './OrganizationAdminServiceFeeExemption.dialog-state'; + +function formatLocalTimestamp(isoTimestamp: string): string { + return new Date(isoTimestamp).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); +} + +function ExemptionStateBadge({ isExempt }: { isExempt: boolean }) { + return ( + + {isExempt ? 'Exempt' : 'Fees apply'} + + ); +} + +export function OrganizationAdminServiceFeeExemption({ + organizationId, +}: { + organizationId: string; +}) { + const exemptionQuery = useAdminOrganizationServiceFeeExemption(organizationId); + const setExemptionMutation = useSetOrganizationServiceFeeExemption(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [reason, setReason] = useState(''); + + if (exemptionQuery.isPending) { + return ; + } + + if (exemptionQuery.isError) { + return ( + + + Service fee exemption + Unable to load the organization service fee exemption. + + + + + + ); + } + + const { current, history } = exemptionQuery.data; + const isExempt = current?.isExempt ?? false; + const actionLabel = isExempt ? 'Revoke exemption' : 'Grant exemption'; + const pendingLabel = isExempt ? 'Revoking exemption…' : 'Granting exemption…'; + const isMutationPending = setExemptionMutation.isPending; + const trimmedReasonLength = reason.trim().length; + const canSubmit = canSubmitServiceFeeExemption({ trimmedReasonLength, isMutationPending }); + const blockDismiss = shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending }); + + const handleConfirm = () => { + if (!canSubmit) return; + + setExemptionMutation.mutate( + { + organizationId, + isExempt: !isExempt, + reason, + }, + { + onSuccess: () => { + toast.success( + isExempt ? 'Service fee exemption revoked' : 'Service fee exemption granted' + ); + setReason(''); + setIsDialogOpen(false); + }, + // On error the dialog stays open and the reason is kept so the admin + // can retry without retyping. + } + ); + }; + + return ( + + +
+
+ + + Service fee exemption + + + Waive Stripe service fees on this organization's new purchases. Exemptions are + not inherited by parent or child organizations. + +
+ +
+
+ + {current ? ( +
+

Current reason

+

{current.reason}

+

+ By{' '} + + {current.changedByKiloUserId ?? 'Deleted admin'} + {' '} + on {formatLocalTimestamp(current.createdAt)} +

+
+ ) : null} + + { + // While the mutation is in flight every close/reopen request + // (Cancel, close button, Escape, overlay) is ignored so the + // dialog cannot be dismissed, reset, or reopened mid-request. + const next = resolveServiceFeeExemptionDialogOpenChange({ + requestedOpen, + isMutationPending, + }); + if (!next) return; + if (next.resetMutation) { + setExemptionMutation.reset(); + } + setIsDialogOpen(next.open); + }} + > + + + + { + if (blockDismiss) event.preventDefault(); + }} + onPointerDownOutside={event => { + if (blockDismiss) event.preventDefault(); + }} + onInteractOutside={event => { + if (blockDismiss) event.preventDefault(); + }} + > + + + {isExempt ? 'Revoke service fee exemption' : 'Grant service fee exemption'} + + + {isExempt + ? 'Stripe service fees apply to this organization’s new purchases again.' + : 'New purchases by this organization skip the Stripe service fee.'}{' '} + The reason is recorded in the admin-only exemption history. + + + +
+ +