diff --git a/src/components/Buzz/BuzzPurchase/BuzzPurchaseImproved.tsx b/src/components/Buzz/BuzzPurchase/BuzzPurchaseImproved.tsx index 6bca3d6704..1481f171d8 100644 --- a/src/components/Buzz/BuzzPurchase/BuzzPurchaseImproved.tsx +++ b/src/components/Buzz/BuzzPurchase/BuzzPurchaseImproved.tsx @@ -71,6 +71,7 @@ import { TurnstileWidget, } from '~/components/TurnstileWidget/TurnstileWidget'; import type { BuzzSpendType } from '~/shared/constants/buzz.constants'; +import { buzzAmountToUnitAmount } from '~/shared/utils/buzz-charge'; import { BuzzTypeSelector } from '~/components/Buzz/BuzzPurchase/BuzzTypeSelector'; import { useBuzzCurrencyConfig } from '~/components/Currency/useCurrencyConfig'; import { GreenEnvironmentRedirect } from '~/components/Purchase/GreenEnvironmentRedirect'; @@ -387,7 +388,7 @@ export const BuzzPurchaseImproved = ({ if (minBuzzAmount) { setSelectedPrice(null); setActiveControl('customAmount'); - setCustomAmount(Math.max(Math.ceil(minBuzzAmount / 10), effectiveMinCharge)); + setCustomAmount(Math.max(buzzAmountToUnitAmount(minBuzzAmount), effectiveMinCharge)); } }, [packages, minBuzzAmount, selectedPrice]); @@ -404,8 +405,11 @@ export const BuzzPurchaseImproved = ({ } }, [selectedBuzzType, features.isGreen, minBuzzAmount, serverDomains.green, syncAccount]); + // Same derivation as the effect above that seeds `customAmount`, so the placeholder shows the + // amount the user will actually be charged. Previously an un-ceiled `/ 10`: for a fractional + // minimum the placeholder advertised one price and the seeded field held a higher one. const minBuzzAmountPrice = minBuzzAmount - ? Math.max(minBuzzAmount / 10, effectiveMinCharge) + ? Math.max(buzzAmountToUnitAmount(minBuzzAmount), effectiveMinCharge) : effectiveMinCharge; // If no buzz type is selected, show selection screen @@ -592,7 +596,18 @@ export const BuzzPurchaseImproved = ({ const newCustomBuzzAmount = value ? Number(value) : undefined; setCustomBuzzAmount(newCustomBuzzAmount); if (newCustomBuzzAmount) { - setCustomAmount(newCustomBuzzAmount / 10); + // This field is free-typed, so a Buzz amount that is + // not a multiple of 10 — 10,004 — divides to 1000.4 + // cents, which Stripe answers with `Invalid integer`. + // `buzzAmountToUnitAmount` owns the rule (and why it + // ceils); it is the only derivation that reaches every + // provider. All four provider schemas now carry `.int()` + // as well, so this is no longer the sole defence. + // The USD field beside this one needs no such guard: + // NumberInputWrapper already applies + // `Math.ceil(value * 100)` to a `format="currency"` + // input. + setCustomAmount(buzzAmountToUnitAmount(newCustomBuzzAmount)); } else { setCustomAmount(undefined); } diff --git a/src/server/schema/__tests__/payment-intent-unit-amount.schema.test.ts b/src/server/schema/__tests__/payment-intent-unit-amount.schema.test.ts new file mode 100644 index 0000000000..fb4d7610e0 --- /dev/null +++ b/src/server/schema/__tests__/payment-intent-unit-amount.schema.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { paymentIntentCreationSchema } from '~/server/schema/stripe.schema'; + +/** + * Stripe amounts are in the currency's MINOR unit and must be integers — `amount: 1000.4` + * is rejected by the API with `Invalid integer: 1000.4`, which reached the client as a + * tRPC INTERNAL_SERVER_ERROR / HTTP 500. + * + * A fraction gets here honestly. The buzz-purchase form derives the USD cents amount from + * the Buzz amount by dividing by 10, so a Buzz amount that is not a multiple of 10 (e.g. + * 10,004) yields 1000.4 cents. The service-side tamper guard compares `unitAmount` against + * `metadata.buzzAmount / 10`, so the pair agrees and nothing between the form and Stripe + * looked at whether the number was whole. + * + * This is the trust boundary — the router feeds this schema straight into + * `getPaymentIntent` — so the integer requirement is pinned here, and a rejection here is a + * tRPC BAD_REQUEST rather than a 500. + */ + +const VALID = { + unitAmount: 1000, + currency: 'USD', + metadata: { + type: 'buzzPurchase' as const, + buzzAmount: 10000, + unitAmount: 1000, + userId: 1, + }, + recaptchaToken: 'token', +}; + +describe('paymentIntentCreationSchema — unitAmount must be a whole minor unit', () => { + it('accepts a whole amount', () => { + const result = paymentIntentCreationSchema.safeParse(VALID); + expect(result.success).toBe(true); + }); + + it('rejects the fractional amount the Buzz-to-USD division produces', () => { + // 10,004 Buzz / 10 = 1000.4 cents — the exact shape Stripe rejected in production. + const result = paymentIntentCreationSchema.safeParse({ + ...VALID, + unitAmount: 1000.4, + metadata: { ...VALID.metadata, buzzAmount: 10004, unitAmount: 1000.4 }, + }); + + expect(result.success).toBe(false); + // Pin the field, not just the failure: an unrelated rule rejecting this input would + // otherwise read as coverage. + expect(result.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('rejects a sub-cent fraction that rounds to the same integer', () => { + // 1000.0001 is in range, is not a multiple-of-ten artifact, and still is not an + // integer. Pinned separately so a fix that only rejects one decimal place fails. + const result = paymentIntentCreationSchema.safeParse({ + ...VALID, + unitAmount: 1000.0001, + metadata: { ...VALID.metadata, buzzAmount: 10000.001, unitAmount: 1000.0001 }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('still rejects an out-of-range whole amount, so the integer rule did not replace the bounds', () => { + const tooSmall = paymentIntentCreationSchema.safeParse({ ...VALID, unitAmount: 1 }); + const tooLarge = paymentIntentCreationSchema.safeParse({ ...VALID, unitAmount: 100_000_000 }); + + expect(tooSmall.success).toBe(false); + expect(tooLarge.success).toBe(false); + }); +}); + +/** + * There was a second route by which a fraction could reach Stripe: + * `createBuzzSessionSchema.customAmount`, handed to `checkout.sessions.create` as + * `unit_amount: customAmount * 100`. Its schema declared `.min()` only, so + * `customAmount: 500.004` parsed and came back `Invalid integer` — the same 500 this file + * exists to close, on a different route. + * + * That route is GONE: #4955 deleted the whole path — schema, service, controller, tRPC + * procedure and the client hook wrapper — and merged separately. It was deleted rather than + * bounded because it had no callers, established against a 3.6-year `Purchase` history, a + * 12-month scan of Stripe Checkout Sessions, and a per-procedure duration histogram that + * records attempts rather than successes. So there is no second route left to bound, and + * this file's `.int()` covers the one that remains. + */ diff --git a/src/server/schema/__tests__/provider-unit-amount-int.schema.test.ts b/src/server/schema/__tests__/provider-unit-amount-int.schema.test.ts new file mode 100644 index 0000000000..a9e9920e80 --- /dev/null +++ b/src/server/schema/__tests__/provider-unit-amount-int.schema.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { createBuzzChargeSchema as coinbaseSchema } from '~/server/schema/coinbase.schema'; +import { createBuzzChargeSchema as emerchantpaySchema } from '~/server/schema/emerchantpay.schema'; +import { transactionCreateSchema as paddleSchema } from '~/server/schema/paddle.schema'; +import { paymentIntentCreationSchema } from '~/server/schema/stripe.schema'; + +/** + * Every provider route that receives the purchase form's derived `unitAmount` must refuse a + * fractional minor unit, not just the Stripe one. + * + * This exists because the round-5 reasoning was wrong and got measured: the claim was that + * `.int()` on the Stripe schema made an open-coded cents division a loud failure. It made it + * loud on STRIPE. The same value is handed to `BuzzCoinbaseButton`, whose schema accepted + * `1000.4`, whose service-side tamper check (`unitAmount !== buzzAmount / 10`) does NOT fire — + * both values come from the same division, so a fractional pair is self-consistent — and which + * forwarded the fraction to `createCharge` as `local_price.amount = "10.004"`, a sub-cent USD + * price. + * + * The bound is asserted per provider rather than centrally on purpose: these are four separate + * trust boundaries with four separate schemas, and a caller can reach any of them directly + * without going through the purchase form at all. + */ + +/** The exact shape the live 500 had: 10,004 Buzz / 10 = 1000.4 cents. */ +const FRACTIONAL = 1000.4; +const WHOLE = 1000; + +describe('every provider refuses a fractional minor unit', () => { + it('stripe', () => { + const base = { + currency: 'USD', + metadata: { type: 'buzzPurchase' as const, buzzAmount: 10_000, unitAmount: WHOLE, userId: 1 }, + recaptchaToken: 'token', + }; + expect(paymentIntentCreationSchema.safeParse({ ...base, unitAmount: WHOLE }).success).toBe( + true + ); + const bad = paymentIntentCreationSchema.safeParse({ ...base, unitAmount: FRACTIONAL }); + expect(bad.success).toBe(false); + expect(bad.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('coinbase', () => { + // Positive control first: the whole amount must still pass, or the bound proves nothing. + expect(coinbaseSchema.safeParse({ unitAmount: WHOLE, buzzAmount: 10_000 }).success).toBe(true); + + const bad = coinbaseSchema.safeParse({ unitAmount: FRACTIONAL, buzzAmount: 10_004 }); + expect(bad.success).toBe(false); + expect(bad.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('emerchantpay', () => { + expect(emerchantpaySchema.safeParse({ unitAmount: WHOLE, buzzAmount: 10_000 }).success).toBe( + true + ); + + const bad = emerchantpaySchema.safeParse({ unitAmount: FRACTIONAL, buzzAmount: 10_004 }); + expect(bad.success).toBe(false); + expect(bad.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('paddle', () => { + // `recaptchaToken` is required; without it the positive control fails and the rejection + // below would pass for the wrong reason — the schema refusing everything. + const base = { recaptchaToken: 'token' }; + expect(paddleSchema.safeParse({ ...base, unitAmount: WHOLE }).success).toBe(true); + + const bad = paddleSchema.safeParse({ ...base, unitAmount: FRACTIONAL }); + expect(bad.success).toBe(false); + expect(bad.error?.issues.map((i) => i.path.join('.'))).toContain('unitAmount'); + }); + + it('the integer rule did not replace paddle existing bounds', () => { + // Guards against a fix that swaps one constraint for another: both bounds must still bite. + const base = { recaptchaToken: 'token' }; + expect(paddleSchema.safeParse({ ...base, unitAmount: 1 }).success).toBe(false); + expect(paddleSchema.safeParse({ ...base, unitAmount: 100_000_000 }).success).toBe(false); + }); + + it('emerchantpay still rejects a non-positive whole amount', () => { + expect(emerchantpaySchema.safeParse({ unitAmount: -100, buzzAmount: 1000 }).success).toBe( + false + ); + }); +}); diff --git a/src/server/schema/coinbase.schema.ts b/src/server/schema/coinbase.schema.ts index 960fa79196..a7111b8f89 100644 --- a/src/server/schema/coinbase.schema.ts +++ b/src/server/schema/coinbase.schema.ts @@ -4,7 +4,21 @@ import { CryptoTransactionStatus } from '~/shared/utils/prisma/enums'; export type CreateBuzzCharge = z.infer; export const createBuzzChargeSchema = z.object({ - unitAmount: z.number(), + // Whole minor units — the same WHOLENESS rule as the Stripe route, and nothing else from + // it: Stripe also carries `.min(minChargeAmount).max(maxChargeAmount)`, this schema carries + // neither, so a negative or a 1e15 `unitAmount` still parses here. That is pre-existing and + // out of scope; this line closes the fraction only. + // + // The purchase form derives this by dividing a free-typed Buzz amount by 10, so any amount + // that is not a multiple of ten yields a fraction — and `coinbase.service.ts` forwards it to + // `createCharge` as `local_price.amount`, i.e. a sub-cent USD price like "10.004". + // + // 🔴 The service-side tamper check (`unitAmount !== buzzAmount / 10`) does NOT catch this: + // both values come from the same division, so a fractional pair is perfectly self-consistent + // and the check passes — structurally, for every non-multiple of ten the double arithmetic + // represents exactly, not at some sampled rate. This line is the only thing on this route + // that rejects the fraction. + unitAmount: z.number().int('The transaction amount must be a whole number of cents'), buzzAmount: z.number(), }); diff --git a/src/server/schema/emerchantpay.schema.ts b/src/server/schema/emerchantpay.schema.ts index 45a38dcc50..18ef4f1920 100644 --- a/src/server/schema/emerchantpay.schema.ts +++ b/src/server/schema/emerchantpay.schema.ts @@ -2,6 +2,11 @@ import { z } from 'zod/v4'; export type CreateBuzzCharge = z.infer; export const createBuzzChargeSchema = z.object({ - unitAmount: z.number().positive('Amount must be positive'), + // Whole minor units, same rule as the Stripe and Coinbase routes — the purchase form's + // Buzz-to-cents division is the shared source of a fraction. See `coinbase.schema.ts`. + unitAmount: z + .number() + .int('The transaction amount must be a whole number of cents') + .positive('Amount must be positive'), buzzAmount: z.number().positive('Buzz amount must be positive'), }); diff --git a/src/server/schema/paddle.schema.ts b/src/server/schema/paddle.schema.ts index 785148243e..18a3bd5958 100644 --- a/src/server/schema/paddle.schema.ts +++ b/src/server/schema/paddle.schema.ts @@ -30,7 +30,13 @@ export const transactionMetadataSchema = z.discriminatedUnion('type', [buzzPurch export type TransactionCreateInput = z.infer; export const transactionCreateSchema = z.object({ - unitAmount: z.number().min(constants.buzz.minChargeAmount).max(constants.buzz.maxChargeAmount), + // Whole minor units, same rule as the Stripe and Coinbase routes — the purchase form's + // Buzz-to-cents division is the shared source of a fraction. See `coinbase.schema.ts`. + unitAmount: z + .number() + .int('The transaction amount must be a whole number of cents') + .min(constants.buzz.minChargeAmount) + .max(constants.buzz.maxChargeAmount), currency: z .string() .default('USD') diff --git a/src/server/schema/stripe.schema.ts b/src/server/schema/stripe.schema.ts index 54aa0da8ee..2fb5e85f9f 100644 --- a/src/server/schema/stripe.schema.ts +++ b/src/server/schema/stripe.schema.ts @@ -71,6 +71,37 @@ export type PaymentIntentCreationSchema = z.infer payload?.name === 'buzz-purchase-amount-mismatch' + ); +} + beforeEach(() => { vi.clearAllMocks(); mockPaymentIntentsCreate.mockResolvedValue({ @@ -118,3 +124,80 @@ describe('getPaymentIntent — buzz purchase currency', () => { expect(overrideLogs()).toHaveLength(0); }); }); + +describe('getPaymentIntent — amount-tamper guard is a 4xx, not a 500', () => { + it('rejects a buzzAmount that is not 10x unitAmount with BAD_REQUEST', async () => { + // The guard is correct and unchanged; what changed is its TYPE. It threw a bare `Error`, + // which `getTRPCErrorFromUnknown` maps to INTERNAL_SERVER_ERROR — so rejected input on + // this route answered with a 500, the same class of defect as the fractional amount that + // Stripe rejected. This is an exposed authenticated procedure. + // + // ⚠️ A mismatched pair is NOT only reachable by hand: `buzzPriceMetadataSchema.buzzAmount` + // is independent of `unitAmount`, so a buzz Price carrying bonus Buzz WOULD trip this from + // an ordinary package click. LATENT, not active — no such Price exists today (all five + // live buzz Prices carry empty metadata, checked 2026-09-19). That is why the log it emits + // is named `-mismatch` rather than `-tamper` — see `stripe.service.ts`, which carries the + // same qualifier. + await expect( + getPaymentIntent({ + unitAmount: UNIT_AMOUNT, + currency: 'USD' as never, + recaptchaToken: 'token', + setupFuturePayment: true, + metadata: { + type: 'buzzPurchase', + buzzAmount: UNIT_AMOUNT * 20, // not 10x — the tampered pair + unitAmount: UNIT_AMOUNT, + userId: USER.id, + }, + user: USER, + customerId: CUSTOMER_ID, + domain: 'green', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(mockPaymentIntentsCreate).not.toHaveBeenCalled(); + }); + + it('logs the rejection, because the 4xx demotion removed its only counter', async () => { + // Demoting this guard 500 -> 400 took its metric with it: `recordTrpcError` increments + // `civitai_app_http_errors_total` only for `status >= 500` (measured live — that counter + // carries 500 and 503 series and NO 4xx), and a 4xx is tagged `type:'info'`, outside the + // error stream. The log below is therefore the ONLY remaining signal that this guard fired. + // Deleting it left the whole suite green until this test existed. + await expect( + getPaymentIntent({ + unitAmount: UNIT_AMOUNT, + currency: 'USD' as never, + recaptchaToken: 'token', + setupFuturePayment: true, + metadata: { + type: 'buzzPurchase', + buzzAmount: UNIT_AMOUNT * 20, + unitAmount: UNIT_AMOUNT, + userId: USER.id, + }, + user: USER, + customerId: CUSTOMER_ID, + domain: 'green', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(mismatchLogs()).toHaveLength(1); + expect(mismatchLogs()[0][0]).toMatchObject({ + type: 'warning', + userId: USER.id, + submittedUnitAmount: UNIT_AMOUNT, + submittedBuzzAmount: UNIT_AMOUNT * 20, + expectedUnitAmount: UNIT_AMOUNT * 2, + }); + }); + + it('stays quiet on a well-formed pair', async () => { + // Negative control: without this, an implementation that logged unconditionally would satisfy + // the assertion above while telling you nothing about whether the guard fired. + await purchase({ domain: 'green' }); + + expect(mismatchLogs()).toHaveLength(0); + }); +}); diff --git a/src/server/services/stripe.service.ts b/src/server/services/stripe.service.ts index 6d07385f0c..16b0dd587c 100644 --- a/src/server/services/stripe.service.ts +++ b/src/server/services/stripe.service.ts @@ -1353,8 +1353,45 @@ export const getPaymentIntent = async ({ } if (unitAmount !== metadata.buzzAmount / 10) { - // Safeguard against tampering with the amount on the client side - throw new Error('There was an error while creating your order. Please try again later.'); + // Safeguard against tampering with the amount on the client side. + // + // Typed rather than a bare `Error`: `getTRPCErrorFromUnknown` maps a plain Error to + // INTERNAL_SERVER_ERROR, so rejected input on this route answered with a 500 — the same + // defect class as the fractional amount above. This is an exposed authenticated + // procedure. The condition is unchanged; only its type. + // + // 🔴 The demotion costs this guard its only COUNTER, which is why the explicit log + // below is not optional. `recordTrpcError` (`server/prom/http-errors.ts`) increments + // `civitai_app_http_errors_total` only for `status >= 500`, and the central error log + // tags a 4xx `type:'info'` — so as a BAD_REQUEST this fires no metric and leaves the + // error stream entirely. A scripted probe hunting for a window where the guard is + // bypassable would otherwise be invisible. + // + // 🔴 Named `-mismatch`, NOT `-tamper`, and deliberately so. Tampering is the motivating case + // but it is not the only way to arrive here: `buzzPriceMetadataSchema.buzzAmount` is an + // INDEPENDENT value with a sibling `bonusDescription`, and the form submits + // `selectedPrice.buzzAmount ?? unitAmount * 10` — so a Stripe buzz Price configured with bonus + // Buzz (charge 1000, credit 11000) trips this condition from an ordinary package click. No + // such Price exists today (all five live buzz Prices carry empty metadata, checked + // 2026-09-19), so this is latent rather than active; but naming the event after the malicious + // reading would attach the word "tamper" — and an innocent buyer's userId — to whoever + // configures the next bonus package. + logToAxiom( + { + name: 'buzz-purchase-amount-mismatch', + type: 'warning', + message: 'rejected a buzz purchase whose unitAmount did not match metadata.buzzAmount', + userId: user.id, + submittedUnitAmount: unitAmount, + submittedBuzzAmount: metadata.buzzAmount, + expectedUnitAmount: metadata.buzzAmount / 10, + }, + 'webhooks' + ).catch(() => null); + + throw throwBadRequestError( + 'There was an error while creating your order. Please try again later.' + ); } // FIN-1: App Blocks revenue attribution is client-forgeable end-to-end — diff --git a/src/shared/utils/__tests__/buzz-charge.test.ts b/src/shared/utils/__tests__/buzz-charge.test.ts new file mode 100644 index 0000000000..e4e30ea3b8 --- /dev/null +++ b/src/shared/utils/__tests__/buzz-charge.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { BUZZ_PER_USD_CENT, buzzAmountToUnitAmount } from '~/shared/utils/buzz-charge'; + +describe('buzzAmountToUnitAmount', () => { + it('returns a whole number of cents for the fractional case that reached Stripe', () => { + // 10,004 Buzz / 10 = 1000.4 — the live `Invalid integer: 1000.4` 500. + expect(buzzAmountToUnitAmount(10_004)).toBe(1001); + }); + + it('returns a whole number of cents for the second live fractional case', () => { + // 8,888 Buzz / 10 = 888.8 — the other event in the 7-day window. + expect(buzzAmountToUnitAmount(8_888)).toBe(889); + }); + + it('ceils rather than rounds, so the buyer is never granted more Buzz than charged for', () => { + // 1000.1 rounds DOWN to 1000 — 1 Buzz granted free. Ceil must give 1001. + expect(buzzAmountToUnitAmount(10_001)).toBe(1001); + // 1000.9 floors DOWN to 1000 for the same reason. + expect(buzzAmountToUnitAmount(10_009)).toBe(1001); + }); + + it('leaves an amount that is already a whole number of cents untouched', () => { + expect(buzzAmountToUnitAmount(10_000)).toBe(1000); + expect(buzzAmountToUnitAmount(5_000)).toBe(500); + }); + + it('never returns a fraction for any Buzz amount in the purchasable range', () => { + for (const buzzAmount of [1_000, 1_001, 1_009, 12_345, 99_999, 1_234_567]) { + const unitAmount = buzzAmountToUnitAmount(buzzAmount); + expect(Number.isInteger(unitAmount)).toBe(true); + } + }); + + // Named for what it actually does. It asserts a LOCAL constant against a literal and + // touches no server code — the server's `/ 10` in `getPaymentIntent` is an independent + // literal, so this can neither detect nor locate a divergence between the two. + // + // 🔴 Nothing pins that relationship structurally. What catches a drifting server ratio is + // `stripe.getPaymentIntent.buzz-currency.test.ts`, and only incidentally: its fixture sets + // `buzzAmount = UNIT_AMOUNT * 10`, so changing the server's divisor makes the well-formed + // pair fail the tamper check and reds several cases there. If you ever change that + // fixture's ratio, the server-side divisor becomes unguarded. + it('pins the local Buzz-per-cent ratio constant', () => { + expect(BUZZ_PER_USD_CENT).toBe(10); + }); +}); diff --git a/src/shared/utils/buzz-charge.ts b/src/shared/utils/buzz-charge.ts new file mode 100644 index 0000000000..6b7868a907 --- /dev/null +++ b/src/shared/utils/buzz-charge.ts @@ -0,0 +1,49 @@ +/** + * Buzz is sold at a fixed ratio of 10 Buzz per USD cent ($1.00 = 1,000 Buzz). + */ +export const BUZZ_PER_USD_CENT = 10; + +/** + * Derive the USD charge, in whole cents, for a Buzz amount. + * + * Every payment provider we hand this value to expects an amount in the + * currency's *minor unit*, which must be a whole number. The division is the + * only place a fraction can be introduced: the Buzz amount is free-typed, so + * anything that is not a multiple of 10 (e.g. 10,004) divides to a fractional + * number of cents. + * + * 🔴 THIS HELPER IS NOT THE ONLY DEFENCE, AND THE SCHEMAS ARE NOT INTERCHANGEABLE + * WITH IT. The four provider ROUTE-INPUT schemas that accept a `unitAmount` now + * carry `.int()` — stripe's `paymentIntentCreationSchema`, coinbase's and + * emerchantpay's `createBuzzChargeSchema`, paddle's `transactionCreateSchema` — so + * a fraction handed to one of THOSE routes is refused at our own trust boundary + * rather than on Stripe alone. Per SCHEMA, not per file: paddle's and stripe's + * nested METADATA schemas each carry a second, still-unbounded `unitAmount`. See + * the Scope block in `stripe.schema.ts`. That was NOT true until it was measured: with this + * helper bypassed, a fractional amount reached `coinbase.service.ts` and left as a + * sub-cent `local_price.amount` of "10.004". + * + * 🔴 "Those routes" is not "every route". `coinbase.createCodeOrder` takes a + * `buzzAmount` and no `unitAmount` at all, then divides by 10 inside + * `coinbase.service.ts` — downstream of every schema bound above, so it can still + * produce a sub-cent `local_price.amount`. It is unreached from the UI and + * deliberately out of scope here. It is NOT covered. + * + * 🔴 Do NOT assume a provider's own tamper check covers this. Coinbase's + * (`unitAmount !== buzzAmount / 10`) compares two values derived from the SAME + * division, so a fractional pair is perfectly self-consistent and it passes. That + * is structural rather than a sampled rate: every Buzz amount the double + * arithmetic represents exactly that is not a multiple of ten yields such a pair, + * so there is no population on which the check does better. (Above 2^53 that + * qualifier bites — 2^55+2 ends in 8 yet divides to an integer — but there NEITHER + * defence fires, so the conclusion is unchanged.) The schema bound is the defence; + * the tamper check is blind to this class. + * + * Ceil, never round or floor: the buyer must never be granted more Buzz than + * they are charged for. The submitted Buzz amount is re-derived from the value + * returned here, so the pair stays consistent with the server-side + * `unitAmount === buzzAmount / 10` tamper check. + */ +export function buzzAmountToUnitAmount(buzzAmount: number): number { + return Math.ceil(buzzAmount / BUZZ_PER_USD_CENT); +}