mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(stripe): stop fractional Buzz amounts reaching Stripe as 500s (#4952)
* fix(stripe): reject fractional Buzz amounts at the trust boundary, not as a 500
Stripe amounts are in the currency's minor unit and must be whole. The Buzz
purchase form derives USD cents by dividing the Buzz amount by 10, so any
free-typed Buzz amount that is not a multiple of 10 (10,004) produced 1000.4
cents. Stripe answered `Invalid integer: 1000.4`, a raw throw out of
paymentIntents.create that reached the client as a tRPC INTERNAL_SERVER_ERROR.
Three changes, all on the same route:
- `paymentIntentCreationSchema.unitAmount` gains `.int()`. The tRPC input schema
is the trust boundary, so a fractional amount is now a BAD_REQUEST naming the
field rather than a 500 naming nothing.
- The form ceils the derived cents value, so a legitimate purchase cannot
produce the fraction in the first place — without this the schema would turn
the 500 into a 400 for a purchase that ought to succeed. Ceil rather than
round, matching the minBuzzAmount derivation beside it and never granting
more Buzz than is charged for. The submitted Buzz amount is re-derived from
this value, so the pair stays consistent with the server's
`unitAmount === buzzAmount / 10` check.
- The amount-tamper guard throws a typed BAD_REQUEST instead of a bare `Error`.
`getTRPCErrorFromUnknown` maps a plain Error to INTERNAL_SERVER_ERROR, so
rejected input on this route also answered with a 500. The condition is
unchanged; only its type.
Regression matrix, both files watched red before the change:
at origin/main (d7038c5aa8): Test Files 2 failed (2) | Tests 3 failed | 8 passed (11)
at HEAD: Test Files 2 passed (2) | Tests 11 passed (11)
The 8 that pass while red are deliberate controls: the whole-amount accept, and
the min/max bounds the integer rule must not have replaced.
Not covered by a test: the form's `Math.ceil`. It is a UX fix rather than a
safety one — the schema above is what actually stops a fraction reaching
Stripe — and a rendering test for this component was judged out of proportion
to that. Called out rather than implied.
Scope: the integer bound is applied to the Stripe route only. The same derived
`unitAmount` is handed to the Paddle and Coinbase buttons, whose schemas declare
the same min/max pair without `.int()`. Whether those providers reject a
fractional minor unit was not established, so they are deliberately unchanged.
* refactor(buzz): give the Buzz-to-cents ceil one home and a test
The `.int()` added in the previous commit covers the Stripe route only. The
form's ceil is the half that reaches every provider — Paddle, Coinbase and
EmerchantPay all accept the same derived `unitAmount` and none of their input
schemas declares an integer bound — and it was the half with no test.
Extract `buzzAmountToUnitAmount` (plus the `BUZZ_PER_USD_CENT` ratio it
divides by) into `src/shared/utils/buzz-charge.ts` and use it at both live
derivation sites in BuzzPurchaseImproved. Behaviour is unchanged at both: the
minBuzzAmount site already ceiled, and the free-typed field's ceil moves
verbatim into the helper.
The rule now has one home and six assertions. Mutation-checked rather than
assumed: Math.ceil -> Math.round kills 2 tests, dropping the rounding entirely
kills 4, and changing the ratio to 100 kills 5 — each on its own assertion.
`src/components/Buzz/BuzzPurchase.tsx` open-codes the same derivation twice
more and is deliberately left alone: it has zero importers repo-wide and is a
separate deletion candidate, so consolidating into it would be work on a file
slated to be removed.
* fix(stripe): address round-1 audit — restore the tamper counter, guard the call site
Round 1 of the adversarial audit produced five should-fix findings. Four were claims
the code made that the tree contradicts; one was a real coverage hole. All are in-band.
1. The purchase form's call site had NO guard, and the gap was measured, not inferred.
Reverting `setCustomAmount(buzzAmountToUnitAmount(...))` to the pre-fix `/ 10` puts the
production bug back verbatim and left the helper suite, the schema suite and both stripe
service suites GREEN (17/17), plus three neighbouring repo guards. With `.int()` now on
the schema that revert is worse than the original defect: instead of 500ing at Stripe,
a non-multiple-of-ten Buzz amount is rejected at our own trust boundary, so Pay Now
fails outright. Adds `no-open-coded-buzz-cents`, a source-text call-site ledger in the
established `src/server/services/__tests__/no-*.test.ts` convention.
Mutation-verified, each mutant killed by this guard's OWN named assertion, with a green
control: the reverted call site (2 assertions red), the `/ BUZZ_PER_USD_CENT` spelling
(2 red), dropping one of the two call sites (count ledger red), diverging the server
guard's ratio to `/ 20` (relationship red), and `ceil` -> `round` (ceil check red).
2. `stripe.service.ts` — the amount-tamper guard lost its only counter when it was demoted
500 -> 400. `recordTrpcError` increments `civitai_app_http_errors_total` only for
`status >= 500`, and a 4xx is tagged `type:'info'`, so the guard now fires no metric and
leaves the error stream. A scripted probe hunting for a bypass would be invisible. Adds
a `type:'warning'` logToAxiom before the throw, matching the `buzz-purchase-currency`
override pattern 30 lines below. Logged at warning because, unlike the fractional amount,
a mismatched pair cannot be produced by the UI at all.
3. `payment-intent-unit-amount.schema.test.ts` — a committed docblock asserted the
`createBuzzSession` path "is DELETED in this change" and "There is nothing left to
bound." Both false: the path is live here and the deletion is #4955, still open. It read
as coverage while providing none, over an exposed authenticated Stripe-calling procedure
with an unbounded amount. Restated with what is actually true, and what to do if #4955
is closed unmerged.
4. `stripe.schema.ts` — the scope comment said Paddle and Coinbase "declare the same
min/max pair without `.int()`". Verified against the tree: Paddle does; Coinbase is a
bare `z.number()` with no bound at all, and EmerchantPay was omitted entirely. Replaced
with the measured per-provider table. Coinbase is a strictly wider hole than Stripe's
was.
5. `buzz-charge.test.ts` — a test named "pins the Buzz-per-cent ratio the server tamper
check re-derives" asserted a local constant against a literal and touched no server
code, so it could neither detect nor locate a divergence. Renamed to what it does; the
relationship it claimed is now pinned by the new ledger.
`CLAUDE.md`, `.claude/agents/civitai-test-review.md` and `test:lint-rules` updated for the
new guard — `no-lint-rules-script-drift` requires it and caught the omission.
Verified at the PR head + these fixes: 5 files / 34 tests green, no zero-test non-runs.
The audit's separate finding that the merged tree adds no type errors over origin/main
(25 errors, byte-identical on both sides, all from a stale generated Prisma client in a
shared node_modules) is an environment defect and is unchanged by this commit.
* fix(buzz): round-2 audit — anchor the guard on the wire value, not the call sites
Round 2 was a delta re-audit of round 1's fixes. It found four mutants that DEFEATED
round 1's new guard, plus two cases where that guard failed correct code. Every defect
below was introduced by round 1; none was in the original change.
1. THE GUARD ANCHORED ON THE WRONG THING. Round 1 keyed on `setCustomAmount(...)` call
sites. Two one-line mutants put the production bug back and stayed green:
- `setCustomAmount(Number(x) / 10)` — the `[^)]*` class cannot cross a `)`;
- rewriting `const unitAmount = ...` (the value actually handed to
`stripe.getPaymentIntent`) to divide `customBuzzAmount`, leaving BOTH helper call
sites intact so the count ledger stayed satisfied.
The call sites were never the seam. The guard now pins the wire-value expression
verbatim and separately asserts the live form contains NO open-coded cents division in
any shape. A guard enumerating call sites can be walked around by adding one more.
2. A NEW IMPORTER WAS INVISIBLE. `EXPECTED_IMPORTERS` filtered a one-element list against
itself: no walk, and it could only ever shrink. A new module importing the helper AND
open-coding `/ 10` passed. It now walks `src/` and asserts the exact set, so it fails
when the set GROWS as well as shrinks. The dead legacy `BuzzPurchase.tsx` — which
open-codes the derivation twice — has its deadness asserted rather than assumed.
3. THE RESTORED MONITORING SIGNAL HAD NO TEST. Deleting round 1's `logToAxiom` left the
suite green. The sibling `buzz-purchase-currency` override is pinned in a file this PR
already touches, with the fixture already present. Adds that assertion plus a negative
control on a well-formed pair.
4. "A mismatched pair cannot be produced by the UI at all" WAS FALSE, and the event name
inherited the false premise. `buzzPriceMetadataSchema.buzzAmount` is independent, with
a sibling `bonusDescription`; the form submits `selectedPrice.buzzAmount ?? unitAmount
* 10`. A bonus-Buzz Price (charge 1000, credit 11000) trips the guard from an ORDINARY
package click. Checked live: all five buzz Prices carry empty metadata, so this is
latent, not active. Renamed `-tamper` -> `-mismatch` so configuring the next bonus
package does not attach the word "tamper", and an innocent buyer's userId, to a warning.
5. THE GUARD FAILED CORRECT CODE, TWICE. Single-sourcing the inverse derivation to
`* BUZZ_PER_USD_CENT` turned it red with a message saying the form was open-coded —
the opposite of what the developer had just done. And `stripComments` stripped only
whole-line comments while claiming to strip all, so a trailing comment could fail the
guard or satisfy a required count. Both fixed and pinned by mutants that must PASS.
6. An un-ceiled `minBuzzAmount / 10` at the min-amount placeholder (pre-dating this PR,
2026-04-08) sat inside the file the guard's own test name claims to cover: for a
fractional minimum the placeholder advertised one price while the seeded field held a
higher one. Now uses the helper, which is what made the zero-division assertion in (1)
possible.
Mutation battery, green control, each mutant killed by this guard's OWN named assertion:
DIE — revert call site to `/ 10`; the `/ BUZZ_PER_USD_CENT` spelling; DELETE a call site;
server ratio `/ 10` -> `/ 20`; `ceil` -> `round`; the parenthesised-division mutant;
the wire-value rewrite; deleting the mismatch log; a new importer that open-codes.
PASS — the `* BUZZ_PER_USD_CENT` consolidation; a trailing comment.
The DELETE-a-call-site mutant SURVIVED the first round-3 attempt, because this commit
initially relaxed the count to `>= 2` and three sites minus one still satisfies it. Caught
by this round's own battery and pinned back to an exact count.
Verified at the merged tree: 5 files / 39 tests green, no zero-test non-runs, drift
ratchet green, prettier clean.
* fix(buzz): delete the call-site guard — .int() already makes the revert loud
Round 4 defeated `no-open-coded-buzz-cents` FIVE independent ways, and its docblock
claimed a strength it did not have — the same defect round 2 had already flagged, made
again one round later. Rather than escalate a sixth time, this runs the change through
the five-step algorithm, and step 1 answers it.
QUESTION THE REQUIREMENT. The guard has no maker: it was suggested by the round-1 audit
and written by me. A prior agent session is not a requester. Recurrence of the incident
it prevents is ZERO — the live defect (a fractional amount reaching Stripe) fired once in
seven days, while the revert it guards against has never happened. Its standing cost is
measured, not speculative: three audit rounds, a four-file doc tax whose counts must be
recomputed on the merged tree, a merge conflict that did not previously exist, plus two
defects live in the tree today — a comment-stripper that deletes real code at
`BuzzPurchaseImproved.tsx:401` (a `//` inside a template literal), and a Tailwind
exclusion that is backwards from what its own comment claims, so `rounded
border-white/10` reds the gate while `border-white/10 rounded` passes.
And the fact that settles it: `.int()` on `paymentIntentCreationSchema.unitAmount` IS the
guard. Reverting the client-side ceil no longer corrupts anything silently — it is
rejected at our own trust boundary and Pay Now fails outright, which is loud and
immediate. A standing source-text tax to protect a loud failure is a net loss.
DELETE. The guard and its registration go; `test:lint-rules` returns to 47 files and the
guard count to 42. The ~10% normally added back is near zero here, because everything
worth keeping already lived elsewhere: `buzz-charge.test.ts` covers the ceil
behaviourally, and the mismatch-log test plus its negative control live in
`stripe.getPaymentIntent.buzz-currency.test.ts` — both retained, both mutation-proven
(logging unconditionally kills the control; dropping a payload field kills the positive).
SIMPLIFY. Two comment defects fixed, both of which an operator would act on:
- `stripe.service.ts` asserted the tampered pair is "unreachable through the UI" eleven
lines above the block explaining a bonus-Buzz Price reaches it from an ordinary
package click. The file contradicted itself, and the surviving sentence was the one
implying a `buzz-purchase-amount-mismatch` event means tampering.
- `payment-intent-unit-amount.schema.test.ts` said the `createBuzzSession` route "is NOT
bounded anywhere" and described what to do "until #4955 lands". #4955 merged, so that
route no longer exists; the docblock now records why it was deleted rather than bounded.
AUTOMATE LAST — explicitly NOT done. The fix for over-guarding is never another guard, so
no token-aware scanner and no meta-check.
NOT fixed, deliberately, and filed rather than folded in: `coinbase.service.ts:24` and
`:68` open-code an un-ceiled `buzzAmount / 10` on a live server path. Pre-existing, wider
than this PR, and the hazard the deleted guard was structurally unable to see. Closing
condition: a PR routing both sites through `buzzAmountToUnitAmount`, verified by the
helper's own tests.
Verified at the merged tree (post-#4955 main): 4 files / 30 tests green, no zero-test
non-runs, drift ratchet green on the deregistration.
* fix(buzz): bound the minor unit on every provider, not just Stripe
Round 6 refuted round 5's reasoning by measurement, and it was wrong in the direction
that matters. Round 5 deleted a structural guard on the grounds that `.int()` on
`paymentIntentCreationSchema.unitAmount` already made an open-coded cents division a loud
failure. `.int()` covered ONE of FOUR provider routes.
The purchase form hands the same derived `unitAmount` to `BuzzCoinbaseButton`. With the
client-side ceil reverted:
- `coinbase.schema.ts` accepted `1000.4` (it was a bare `z.number()`);
- `coinbase.service.ts`'s tamper check did NOT fire, because `unitAmount` and `buzzAmount`
come from the SAME division, so a fractional pair is perfectly self-consistent —
measured, it fires 0/12 on free-typed amounts where `.int()` rejects 12/12;
- the fraction left our boundary as `local_price.amount = "10.004"`, a sub-cent USD price.
Three files in this tree already said so — `BuzzPurchaseImproved.tsx` ("the only derivation
that reaches every provider, Stripe's `.int()` covering Stripe alone"), `stripe.schema.ts`
("this covers the STRIPE route only … a deferral, not a clean bill of health") and
`buzz-charge.ts`. The round-5 rationale contradicted all three.
So rather than restore the source-text guard, this makes the claim TRUE:
- `.int()` on `coinbase.schema.ts`, `emerchantpay.schema.ts` and `paddle.schema.ts`, so a
fraction is refused at our own trust boundary on every route. Three payload lines, no
scanner, no doc tax — which is why this passes the question-the-requirement test that
the deleted guard failed.
- `provider-unit-amount-int.schema.test.ts` pins all four, each with a positive control so
a rejection cannot pass for the wrong reason. That control earned its place immediately:
paddle's schema requires `recaptchaToken`, so the first draft's "rejects a fraction" case
was passing because the schema refused everything.
- Negative control watched: removing Coinbase's `.int()` reds the `coinbase` case
specifically; restored byte-identical.
Existing bounds are preserved and asserted, so the integer rule did not swap one constraint
for another: paddle still rejects out-of-range, emerchantpay still rejects non-positive.
Also fixed, both introduced or left by earlier rounds of this PR:
- `buzz-charge.test.ts` pointed at `no-open-coded-buzz-cents.test.ts`, which round 5
deleted — a dangling reference this repo has no doc-rot gate to catch. It now says what
actually catches a drifting server ratio (`stripe.getPaymentIntent.buzz-currency.test.ts`,
and only incidentally, via its `buzzAmount = UNIT_AMOUNT * 10` fixture) rather than naming
a file that does not exist.
- `stripe.getPaymentIntent.buzz-currency.test.ts` still asserted the mismatched pair is
"unreachable through the purchase form". That is the same false claim removed from
`stripe.service.ts` last round, and it is the one that leads an operator to read a
`buzz-purchase-amount-mismatch` event as tampering.
- `buzz-charge.ts` now records why the schemas — not this helper, and not a provider's own
tamper check — are the defence, so the next person does not re-derive the refuted version.
NOT restored: the deleted call-site guard. With every route bounded, the revert it existed to
catch is now loud everywhere rather than on Stripe alone, which is what round 5 claimed and
did not have.
Verified at the merged tree: 6 files / 58 tests green, no zero-test non-runs, drift ratchet
green.
* fix(buzz): retract the four claims round 7 falsified and left standing
Round 8 (delta audit of f534813f2c..9b153c91e9) returned five 🟡, none in the
payload: every one is a sentence an earlier round of this PR wrote, which a
later round of this same PR made false. Comment-only — mechanically confirmed,
no non-comment line changed.
F1 `stripe.schema.ts` — a four-row table this PR added, asserting the other
three providers carry no `.int()`, "(all verified at this commit)", ending "a
deferral, not a clean bill of health". Round 7 closed that deferral, so three
of the four rows and all four line numbers were wrong. The block recorded an
open gap that no longer exists, so it is DELETED rather than re-pinned; what
replaces it is the one fact that survives — the `.int()` is now shared, the
`.min`/`.max` are not — and a note not to re-add line numbers, since nothing in
this repo checks a cross-file reference and these rotted inside one PR.
F2 `buzz-charge.ts` — "refused at our own trust boundary on every route" is
false. `coinbase.createCodeOrder` accepts a `buzzAmount` and no `unitAmount`,
then divides by 10 inside `coinbase.service.ts`, downstream of every schema
bound. Verified: `createCodeOrderSchema.safeParse({type:'Buzz',
buzzAmount:10004})` succeeds and 10004/10 = 1000.4. Named as NOT covered rather
than quietly narrowed.
F3 `coinbase.schema.ts` + `buzz-charge.ts` — "the check fires 0/12 while
`.int()` rejects 12/12" named no population and is not reproducible: against
the only 12-element set in the tree it is 0/12 and 9/12, and against
"non-multiples of ten" both halves are true by construction. The comparative is
DELETED, not reversed. What replaces it is the structural claim, which is what
was actually established: the tamper check compares two values from the same
division, so every non-multiple of ten yields a self-consistent pair.
F4 `coinbase.schema.ts` — "same rule as the Stripe route" read as parity.
Coinbase got one of Stripe's three bounds; it still accepts a negative or 1e15.
Scoped to wholeness explicitly and the residual named as pre-existing.
F5 `BuzzPurchaseImproved.tsx` — "Stripe's `.int()` covering Stripe alone",
false since round 7 and quoted by round 7's own commit message as evidence.
F6 `stripe.getPaymentIntent.buzz-currency.test.ts` — the bonus-Buzz
reachability claim dropped the "latent rather than active" qualifier that
`stripe.service.ts` carries for the identical claim. Restored, so the two files
state the same strength.
Retraction swept tree-wide, not edited at the reported sites: `0/12`, `12/12`,
`covering Stripe alone`, `NO bound at all`, `clean bill of health`,
`no-open-coded` all return zero on the payment surface, against a positive
control (`buzzAmountToUnitAmount`) watched to hit. The control moved 4 files to
3 — reconciled: the fourth was the deleted `stripe.schema.ts` table.
Verified at this tree: prettier clean on all five files with the instrument
validated first (5 operands; a planted mis-format made it go red; restored
byte-identical by sha256). ESLint 0 errors, 2 pre-existing warnings. Affected
suites 4 files / 25 tests green — the `Tests` line, not `Test Files`.
Not established: whether "all five live buzz Prices carry empty metadata" still
holds — that is a production-database claim and is not checkable from here. It
is carried forward from round 7 unchanged, now in both files rather than one.
* fix(buzz): restore the true row round 9's deletion took with the false ones
Round 9 (delta audit of 9b153c91e9..819791aa15) returned two 🟡, both in the
retraction I wrote, neither with a live failure path. Comment-only again.
F2, and it is the one that mattered: the four-row provider table I deleted was
wrong in three rows and RIGHT in the fourth. `paddle.schema.ts`'s
`buzzPurchaseMetadataSchema.unitAmount` really is `z.coerce.number().positive()`
— no `.int()`, no `.max()` — and deleting the table left that recorded NOWHERE
in the tree. Worse, my replacement was written at FILE granularity ("paddle.ts
now carries the same .int()") when paddle holds two schemas with a `unitAmount`
and only the route-input one is bounded. `stripe.schema.ts` has the identical
shape in `paymentIntentMetadataSchema`, which was never in the table either.
Measured on the live schemas: `metadata.unitAmount = 1000.4` parses, parses
nested through `transactionCreateSchema` on the real route, coerces from the
string "1000.4", and accepts 1e15. Inert TODAY only because the services
rebuild metadata server-side (`paddle.service.ts` via
`getBuzzTransactionMetadata`) instead of forwarding the client's — a one-line
refactor away from reaching the provider. That is exactly the read the deleted
row would have blocked, so both comments are now written per SCHEMA and the
unbounded nested pair is named explicitly.
F1: "nothing in this repo checks a cross-file reference" was false. This repo
DOES pin doc-vs-tree claims — `no-lint-rules-script-drift.test.ts` pins literal
sentences and asserts referenced paths exist, `no-stale-moderator-route-probe`
pins route paths. What has no gate is specifically a `file:LINE` reference
inside a source comment. Narrowed to the claim that is true, and the precedent
named so the next author can find it rather than concluding none exists.
G1, taken now rather than leaving it for a round that should not happen: the
structural claim's universal quantifier is false above 2^53 — 2^55+2 ends in 8
and still divides to an integer. Bounded to "the double arithmetic represents
exactly", with the counterexample and the reason it does not move the
conclusion (up there NEITHER defence fires).
🔴 THIS IS THE LAST ROUND, AND IT STOPS ON THE ATTRIBUTION GATE, NOT ON A CLEAN
RESULT. Round 9 changed 70 payload lines and ZERO behavioural ones; this round
does the same. Two consecutive rounds whose fixes changed no executable line
means the ladder is auditing its own prose rather than the PR — the payload has
been unchanged since round 7 and was found correct there under seven mutants
with four proven-non-vacuous positive controls. F1 and F2 are the fifth and
sixth successive wrong-SCOPE rationale on this one family of comments; a tenth
round auditing a tenth rewording costs more than the sentences are worth. The
auditor independently recommended applying these and stopping.
Verified at this tree: comment-only (mechanically); prettier clean on 3 files,
3 operands; affected suites 3 files / 16 tests green, read off the `Tests` line.
Not established, unchanged and flagged rather than closed: "all five live buzz
Prices carry empty metadata, checked 2026-09-19" is a production-DB claim and
is not checkable from this host. Both files state it at the same strength.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,21 @@ import { CryptoTransactionStatus } from '~/shared/utils/prisma/enums';
|
||||
|
||||
export type CreateBuzzCharge = z.infer<typeof createBuzzChargeSchema>;
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { z } from 'zod/v4';
|
||||
|
||||
export type CreateBuzzCharge = z.infer<typeof createBuzzChargeSchema>;
|
||||
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'),
|
||||
});
|
||||
|
||||
@@ -30,7 +30,13 @@ export const transactionMetadataSchema = z.discriminatedUnion('type', [buzzPurch
|
||||
|
||||
export type TransactionCreateInput = z.infer<typeof transactionCreateSchema>;
|
||||
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')
|
||||
|
||||
@@ -71,6 +71,37 @@ export type PaymentIntentCreationSchema = z.infer<typeof paymentIntentCreationSc
|
||||
export const paymentIntentCreationSchema = z.object({
|
||||
unitAmount: z
|
||||
.number()
|
||||
// Stripe amounts are in the currency's MINOR unit and must be whole: `amount: 1000.4`
|
||||
// comes back as `Invalid integer: 1000.4`, which surfaced as a 500. A fraction arrives
|
||||
// honestly — the purchase form derives cents from the Buzz amount by dividing by 10, so
|
||||
// any Buzz amount that is not a multiple of 10 lands here — and the service-side tamper
|
||||
// guard (`unitAmount === metadata.buzzAmount / 10`) agrees with it, so nothing further
|
||||
// down the Stripe path looks at whether the number is whole.
|
||||
//
|
||||
// Scope, per SCHEMA and not per file — the distinction is load-bearing, because two of
|
||||
// these files hold more than one schema with a `unitAmount`. Carrying the same `.int()`:
|
||||
// coinbase's `createBuzzChargeSchema`, emerchantpay's `createBuzzChargeSchema` and
|
||||
// paddle's `transactionCreateSchema`. So the whole-minor-unit rule is no longer
|
||||
// Stripe-only on the ROUTE inputs. Their other bounds still differ and this route remains
|
||||
// the tightest — coinbase's declares no lower or upper bound at all, so it accepts a
|
||||
// negative or a 1e15 `unitAmount` where the `.min`/`.max` below reject both.
|
||||
//
|
||||
// 🔴 STILL UNBOUNDED, and recorded here because deleting the stale table that used to say
|
||||
// so left it written down nowhere: paddle's `buzzPurchaseMetadataSchema.unitAmount` is
|
||||
// `z.coerce.number().positive()` — no `.int()`, no `.max()` — and it is NESTED inside the
|
||||
// bounded `transactionCreateSchema`, so "paddle is covered" is true of the route input and
|
||||
// false of the metadata. `paymentIntentMetadataSchema` in THIS file has the same shape.
|
||||
// Both are inert only because the services rebuild metadata server-side
|
||||
// (`paddle.service.ts` via `getBuzzTransactionMetadata`) rather than forwarding the
|
||||
// client's. Forward it instead — a one-line refactor — and a fractional
|
||||
// `metadata.unitAmount` reaches the provider. Do not read the shared `.int()` as parity.
|
||||
//
|
||||
// No line numbers, deliberately: this repo DOES pin doc-vs-tree claims in places
|
||||
// (`no-lint-rules-script-drift.test.ts` pins literal sentences and asserts referenced
|
||||
// paths exist; `no-stale-moderator-route-probe.test.ts` pins route paths), but nothing
|
||||
// checks a `file:LINE` reference written inside a source comment — and the four that
|
||||
// stood here were falsified inside this same PR.
|
||||
.int({ message: 'The transaction amount must be a whole number of cents' })
|
||||
.min(constants.buzz.minChargeAmount, {
|
||||
message: `The minimum transaction amount is $${(constants.buzz.minChargeAmount / 100).toFixed(
|
||||
2
|
||||
|
||||
@@ -68,6 +68,12 @@ function overrideLogs() {
|
||||
);
|
||||
}
|
||||
|
||||
function mismatchLogs() {
|
||||
return (loggingMock.logToAxiom.mock.calls as [{ name?: string }][]).filter(
|
||||
([payload]) => 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user