mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(app-blocks): author-fee viewer-charge path (slice 2b, dark)
Slice 1 computed the per-generation author fee and threw it away. Slice 2a
persisted an accrual ledger and a settlement rail with ZERO callers. This slice
is the caller: it prices the fee, debits the viewer, writes the accrual and
reverses both when the generation does not survive.
THE FEE IS PRICED INTO THE RESERVATION, NOT DEBITED OUTSIDE IT. This is the one
safety hole the design review found. The submit order on every path is
whatIf -> budget gate + reservations -> submitWorkflow -> charge. A fee debited
at the end would escape all five guardrails those reservations implement: the
token per-call budget, the per-user daily cap, the viewer's own per-app consent
budget, the per-app aggregate cap and the dev-tunnel backstop. The consent
budget in particular would then bound the part of the price the app does NOT
set while leaving the part it DOES set unbounded.
So the path splits in two. quoteBlockAuthorFee runs against the whatIf response
and its feeBuzz is folded into the number every gate and reservation reads.
chargeBlockAuthorFee runs against the realized submit response and takes the
reserved amount as a HARD CEILING - charged = min(reserved, realized). A path
that reserved nothing passes 0 and can then charge nothing, whatever the
realized base says, so the property is structural rather than conventional.
Two of the four submit paths are in that state deliberately: customComfy takes
no whatIf quote at all (its ceiling IS the declared maxBuzz) and the
pass-through quote helper returns a total only. Neither has a pre-submit
cost.base to price a fee from. Both still call the charge with 0 so the
population of submit paths stays closed and the seam guard can enumerate it.
SELF-DEALING IS READ BEFORE THE DEBIT. Slice 2a implemented the exclusion inside
the accrual writer and left a note saying a charge path must call it before the
debit or extract it. It is extracted - resolveBlockAuthorFeePayee - and the
quote calls it, so a self-dealing generation never even has a fee reserved. The
accrual still calls it as the write-side belt for any other caller. The owner
read stays in the file the ownership-gate ledger already enumerates.
THE FEE FOLLOWS THE GENERATION'S REFUND. pollWorkflow and cancelAppWorkflow
reverse on any non-succeeded terminal status: the debit is refunded and the
unsettled accrual row deleted. The DELETE is status-guarded, which is both the
settled-row refusal and the lock - only the caller whose delete matched issues
the refund, so concurrent terminal polls cannot double-refund. A SETTLED row is
refused rather than clawed back. The succeeded guard matters most on cancel,
which races completion.
Reconciled BY COUNT throughout: createBuzzTransactionMany does not throw on a
per-transaction failure, it drops the result from both arrays. A landed debit
whose accrual then failed is refunded under the reversal key, so a later
terminal reversal conflicts instead of paying twice.
NO MIGRATION. There is no clawed_back status: the status CHECK is constrained to
('accrued','settled'), every migration here is applied by hand per environment,
and a status-guarded DELETE is the atomic claim the reversal needs anyway. The
consequence is stated in the code - a reversed generation leaves no row, so the
reversal count lives only in the block-author-fee Axiom stream.
STILL DARK. Every entry point is behind app-blocks-author-fee-enabled, which is
false. With the flag off no fee is quoted, so nothing is reserved, so the clamp
makes a charge impossible and the ledger stays empty.
Also folds the txt2img spend-basis derivation into the shared
deriveBlockSpendBasis the other three paths already used - the fee and the
attribution must agree on the currency, and the inline copy was unreachable
from where the fee needed the answer. The attribution fallback keeps reading
the orchestrator's own quoted total, not the fee-inclusive cost.
TESTS
- src/server/services/__tests__/no-divergent-author-fee-base.test.ts gains 9
guards over the router call sites. Measured at dce428a492: 7 RED, 2 VACUOUSLY
GREEN. The two green ones iterate the (empty) call-site array, so they assert
nothing there; what stops that being a hole is the positive control and the
count assertion in the same describe, both of which are red. This is the
regression coverage for the change.
- author-fee-charge.service.test.ts is 29 NEW-MODULE tests, not regression
coverage - at the base they cannot run at all because the module does not
exist. What makes them real is the mutation sweep: 21 counted mutants, all 21
killed, each by its own named assertion. A 22nd was RETRACTED rather than
reported as a survivor - it inserted a self-dealing branch AFTER
resolveBlockAuthorFeePayee had already returned, so it was unreachable and
proved nothing either way. Its replacements break the real predicate
(M4a/M4b) and kill the charge-path test, which is what proves the charge
actually reaches the shared predicate rather than merely importing it.
- Prisma is mocked at the module boundary in every one of them, so none of this
is a claim about behaviour against a real database.
This commit is contained in:
@@ -192,6 +192,17 @@ import {
|
|||||||
// the `kind` → key and step-id → key mapping has exactly one definition — and so
|
// the `kind` → key and step-id → key mapping has exactly one definition — and so
|
||||||
// the "registry id, never orchestratorType" decision lives in one place.
|
// the "registry id, never orchestratorType" decision lives in one place.
|
||||||
import { resolveBlockGenerationType } from '~/server/services/blocks/generation-type';
|
import { resolveBlockGenerationType } from '~/server/services/blocks/generation-type';
|
||||||
|
// The author-fee viewer-charge path (slice 2b). Imported STATICALLY, not through
|
||||||
|
// the `await import()` this file uses for most services: the quote runs on the
|
||||||
|
// submit path before the orchestrator is called, and its transitive graph
|
||||||
|
// (buzz.service, the db client, app-blocks-flag) is already statically imported
|
||||||
|
// here, so a dynamic form would defer nothing. Everything it exports is
|
||||||
|
// fail-closed behind `app-blocks-author-fee-enabled`.
|
||||||
|
import {
|
||||||
|
chargeBlockAuthorFee,
|
||||||
|
quoteBlockAuthorFee,
|
||||||
|
reverseBlockAuthorFee,
|
||||||
|
} from '~/server/services/blocks/author-fee-charge.service';
|
||||||
// Moderation dispatch for the same registry. A SEPARATE module because it pulls
|
// Moderation dispatch for the same registry. A SEPARATE module because it pulls
|
||||||
// `auditPromptServer` (Redis + ClickHouse + DB + notifications) and the registry
|
// `auditPromptServer` (Redis + ClickHouse + DB + notifications) and the registry
|
||||||
// itself is imported by `workflow.schema` for the wire enum, which must stay
|
// itself is imported by `workflow.schema` for the wire enum, which must stay
|
||||||
@@ -3957,6 +3968,25 @@ export const blocksRouter = router({
|
|||||||
workflowId: input.workflowId,
|
workflowId: input.workflowId,
|
||||||
actualCost: snapshot.cost?.total ?? 0,
|
actualCost: snapshot.cost?.total ?? 0,
|
||||||
});
|
});
|
||||||
|
// 🔴 THE AUTHOR FEE FOLLOWS THE GENERATION'S OWN REFUND. A workflow that
|
||||||
|
// failed, expired or was cancelled is refunded by the orchestrator — in
|
||||||
|
// full when it delivered nothing, prorated by undelivered output
|
||||||
|
// otherwise — so a fee left standing on it charges the viewer for an
|
||||||
|
// app's contribution to work they never received, and pays the author out
|
||||||
|
// of it on the next settlement run. Reverses the debit and deletes the
|
||||||
|
// unsettled accrual; a SETTLED row is refused, not clawed back.
|
||||||
|
//
|
||||||
|
// Self-scoping and idempotent, for the same reasons `settleCustomComfySpend`
|
||||||
|
// above is: the reversal claims its row with a status-guarded DELETE, so
|
||||||
|
// only one of the many terminal polls this proc serves can ever refund,
|
||||||
|
// and a workflow that never accrued a fee no-ops. Never throws — this
|
||||||
|
// proc's contract is to return the snapshot.
|
||||||
|
if (snapshot.status !== 'succeeded') {
|
||||||
|
await reverseBlockAuthorFee({
|
||||||
|
workflowId: input.workflowId,
|
||||||
|
terminalStatus: snapshot.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { snapshot };
|
return { snapshot };
|
||||||
}),
|
}),
|
||||||
@@ -4103,6 +4133,27 @@ export const blocksRouter = router({
|
|||||||
workflowId: input.workflowId,
|
workflowId: input.workflowId,
|
||||||
actualCost: snapshot.cost?.total ?? 0,
|
actualCost: snapshot.cost?.total ?? 0,
|
||||||
});
|
});
|
||||||
|
// 🔴 THE AUTHOR FEE FOLLOWS THE GENERATION — see `pollWorkflow`. A cancel is
|
||||||
|
// the same question as a terminal poll: the orchestrator prorates a
|
||||||
|
// non-customComfy cancel by undelivered output (a job that delivered nothing
|
||||||
|
// refunds in full), so the fee goes back whole. It is the conservative
|
||||||
|
// direction, chosen deliberately: this path has no settled-cost number to
|
||||||
|
// prorate a fee against, and over-refunding a ≤100 ⚡ fee is the error to
|
||||||
|
// make. Self-scoping, idempotent (status-guarded DELETE) and non-throwing.
|
||||||
|
//
|
||||||
|
// 🔴 THE `succeeded` GUARD IS NOT COPY-PASTE FROM THE POLL — THIS PATH IS
|
||||||
|
// WHERE IT EARNS ITS KEEP. A cancel RACES completion: the workflow can
|
||||||
|
// finish between the scope read and `cancelWorkflow`, and the re-read below
|
||||||
|
// then reports `succeeded`. The viewer got their generation and the
|
||||||
|
// orchestrator refunds nothing, so reversing the fee there would hand back
|
||||||
|
// money for work that was delivered — the only direction of this reversal
|
||||||
|
// that costs the author rather than protecting the viewer.
|
||||||
|
if (snapshot.status !== 'succeeded') {
|
||||||
|
await reverseBlockAuthorFee({
|
||||||
|
workflowId: input.workflowId,
|
||||||
|
terminalStatus: snapshot.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
return { snapshot };
|
return { snapshot };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -5458,7 +5509,47 @@ export const blocksRouter = router({
|
|||||||
},
|
},
|
||||||
query: { whatif: true },
|
query: { whatif: true },
|
||||||
});
|
});
|
||||||
const cost = whatIfResult.cost?.total ?? 0;
|
// 🔴 THE AUTHOR FEE IS PRICED INTO `cost`, HERE, BEFORE ANY GATE OR ANY
|
||||||
|
// RESERVATION READS IT. That placement is the safety property, not a
|
||||||
|
// convenience: every guardrail below — the token's per-call `buzzBudget`,
|
||||||
|
// the per-user daily cap, the viewer's OWN per-app CONSENT BUDGET, the
|
||||||
|
// per-app aggregate cap and the dev-tunnel backstop — is taken against this
|
||||||
|
// number. A fee debited after the submit instead would escape all five, and
|
||||||
|
// the consent budget in particular would bound the part of the price the app
|
||||||
|
// does NOT set while leaving the part it DOES set unbounded.
|
||||||
|
//
|
||||||
|
// Priced off the whatIf's `cost.base` / `cost.variable`, for the same reason
|
||||||
|
// the realized ones below are read off the raw response: `snapshot.cost` is
|
||||||
|
// deliberately `{ total }` only. Fail-closed behind the flag and
|
||||||
|
// non-throwing — an unavailable quote is simply no fee.
|
||||||
|
//
|
||||||
|
// 🔴 RESOLVED ONCE, HERE, AND READ BY ALL THREE CONSUMERS. The fee quote,
|
||||||
|
// the fee charge and the spend-attribution row must agree on the generation
|
||||||
|
// type or the fee is PRICED under one key and RECORDED under another — a
|
||||||
|
// per-type override (`chat-completion` is 0/0 in the platform table) would
|
||||||
|
// then apply to one and not the other, silently. It is pure and
|
||||||
|
// non-throwing; see the long note at the attribution call site for why
|
||||||
|
// `generateInput.workflow` is the authoritative image-workflow class and
|
||||||
|
// why re-deriving it there would be wrong.
|
||||||
|
const blockGenerationType = resolveBlockGenerationType(textToImageBody, {
|
||||||
|
imageWorkflowType: generateInput.workflow,
|
||||||
|
});
|
||||||
|
const authorFeeQuote = await quoteBlockAuthorFee({
|
||||||
|
baseGenerationBuzz: whatIfResult.cost?.base,
|
||||||
|
priceIsCap: whatIfResult.cost?.variable,
|
||||||
|
generationType: blockGenerationType,
|
||||||
|
appId: claims.appId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
workflowLabel: blockExternalId,
|
||||||
|
});
|
||||||
|
const reservedAuthorFeeBuzz = authorFeeQuote.charge ? authorFeeQuote.feeBuzz : 0;
|
||||||
|
// 🔴 KEPT SEPARATE FROM `cost`, AND THE SEPARATION IS LOAD-BEARING. `cost`
|
||||||
|
// is now generation + fee, which is right for every gate and reservation.
|
||||||
|
// The spend-attribution row's FALLBACK basis is a different question — it
|
||||||
|
// records what the platform took for the GENERATION — so it must keep
|
||||||
|
// reading the orchestrator's own number, not one this line inflated.
|
||||||
|
const quotedGenerationBuzz = whatIfResult.cost?.total ?? 0;
|
||||||
|
const cost = quotedGenerationBuzz + reservedAuthorFeeBuzz;
|
||||||
if (cost > claims.buzzBudget) {
|
if (cost > claims.buzzBudget) {
|
||||||
return {
|
return {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -5984,6 +6075,41 @@ export const blocksRouter = router({
|
|||||||
// a 'failed' status without queueing) has no generation to attribute.
|
// a 'failed' status without queueing) has no generation to attribute.
|
||||||
const spendWorkflowId = snapshot.workflowId;
|
const spendWorkflowId = snapshot.workflowId;
|
||||||
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
||||||
|
// 🔴 ONE DERIVATION, TWO CONSUMERS. This used to be open-coded inside the
|
||||||
|
// attribution closure below while `deriveBlockSpendBasis` held a second,
|
||||||
|
// identical copy for the other three submit paths — and the closure's copy
|
||||||
|
// is unreachable from here, where the fee needs the same answer. Two
|
||||||
|
// spellings of one rule regenerate the same bug at both, so the inline
|
||||||
|
// copy is gone and every path now asks the one helper. The FALLBACK is the
|
||||||
|
// orchestrator's own quoted total, never `cost`, which now carries the fee.
|
||||||
|
const spendBasis = deriveBlockSpendBasis(
|
||||||
|
realizedTransactions,
|
||||||
|
isGreen,
|
||||||
|
snapshot.cost?.total ?? quotedGenerationBuzz
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔴 THE VIEWER-FACING DEBIT, AWAITED — not fire-and-forget like the
|
||||||
|
// attribution below it. A viewer who was charged and whose accrual did not
|
||||||
|
// land is a real loss to a real author, so the result has to be observed.
|
||||||
|
// It never throws: the submit has already succeeded and the snapshot is
|
||||||
|
// owed to the block.
|
||||||
|
//
|
||||||
|
// 🔴 `reservedAuthorFeeBuzz` IS A CEILING. The charge re-prices off the
|
||||||
|
// REALIZED base and takes `min(reserved, realized)`, so the viewer can
|
||||||
|
// never be billed past the number every gate above was measured against.
|
||||||
|
// D6: the fee is charged in the SAME currency the generation drained.
|
||||||
|
await chargeBlockAuthorFee({
|
||||||
|
workflowId: spendWorkflowId,
|
||||||
|
appId: claims.appId,
|
||||||
|
appBlockId: claims.appBlockId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
buzzType: spendBasis.buzzType,
|
||||||
|
baseGenerationBuzz: realizedBaseCost,
|
||||||
|
priceIsCap: realizedPriceIsCap,
|
||||||
|
generationType: blockGenerationType,
|
||||||
|
reservedAuthorFeeBuzz,
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { recordSpendAttribution } = await import(
|
const { recordSpendAttribution } = await import(
|
||||||
'~/server/services/blocks/buzz-attribution.service'
|
'~/server/services/blocks/buzz-attribution.service'
|
||||||
@@ -6047,41 +6173,13 @@ export const blocksRouter = router({
|
|||||||
// client input.
|
// client input.
|
||||||
// ALL paid-account (green/yellow) entries — debits AND credits — so we
|
// ALL paid-account (green/yellow) entries — debits AND credits — so we
|
||||||
// can net them. Blue/fakeRed are excluded by isPayoutEligibleBuzz.
|
// can net them. Blue/fakeRed are excluded by isPayoutEligibleBuzz.
|
||||||
const paidEntries = (realizedTransactions?.list ?? []).filter((t) =>
|
// `spendBasis` is derived above, in the enclosing scope, by the shared
|
||||||
isPayoutEligibleBuzz(t.accountType)
|
// `deriveBlockSpendBasis` — see the note there for why the copy that
|
||||||
);
|
// used to live on these lines is gone.
|
||||||
// Defensive guard against a FUTURE change that offers BOTH green and
|
|
||||||
// yellow (today the contract is ['blue', green|yellow], so at most one
|
|
||||||
// paid account is touched). If more than one distinct paid accountType
|
|
||||||
// shows up we can't attribute a single paid currency, so refuse to
|
|
||||||
// conflate them and fall back to the conservative blue floor below.
|
|
||||||
const distinctPaidTypes = new Set(paidEntries.map((t) => t.accountType));
|
|
||||||
// NET the paid account: debits add, credits (refunds/corrections in the
|
|
||||||
// same workflow) subtract. A net <= 0 means nothing was net-paid → floor.
|
|
||||||
const netPaidAmount =
|
|
||||||
distinctPaidTypes.size > 1
|
|
||||||
? 0
|
|
||||||
: paidEntries.reduce(
|
|
||||||
(sum, t) =>
|
|
||||||
sum + (t.type === 'debit' ? Math.abs(t.amount ?? 0) : -Math.abs(t.amount ?? 0)),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const hasPaidDebit = distinctPaidTypes.size === 1 && netPaidAmount > 0;
|
|
||||||
// `isPayoutEligibleBuzz` already narrowed accountType to green|yellow,
|
|
||||||
// both valid `BuzzSpendType`s; size===1 ⇒ every paid entry shares it.
|
|
||||||
const paidType = hasPaidDebit ? (paidEntries[0].accountType as BuzzSpendType) : undefined;
|
|
||||||
|
|
||||||
// paidType is set iff hasPaidDebit; otherwise fall to the conservative
|
|
||||||
// free floor (getBlockAllowedAccountTypes[0] === 'blue' in both branches).
|
|
||||||
const spentBuzzType: BuzzSpendType = paidType ?? getBlockAllowedAccountTypes(isGreen)[0];
|
|
||||||
const spentBuzzAmount = hasPaidDebit
|
|
||||||
? netPaidAmount
|
|
||||||
: Math.ceil(snapshot.cost?.total ?? cost);
|
|
||||||
|
|
||||||
await recordSpendAttribution({
|
await recordSpendAttribution({
|
||||||
userId,
|
userId,
|
||||||
buzzAmount: spentBuzzAmount,
|
buzzAmount: spendBasis.buzzAmount,
|
||||||
buzzType: spentBuzzType,
|
buzzType: spendBasis.buzzType,
|
||||||
workflowId: spendWorkflowId,
|
workflowId: spendWorkflowId,
|
||||||
appId: claims.appId,
|
appId: claims.appId,
|
||||||
appBlockId: claims.appBlockId,
|
appBlockId: claims.appBlockId,
|
||||||
@@ -6114,9 +6212,11 @@ export const blocksRouter = router({
|
|||||||
// (the builder returns `Record<string, unknown>`); the resolver
|
// (the builder returns `Record<string, unknown>`); the resolver
|
||||||
// bounds it and degrades to the bare `textToImage` key if it is
|
// bounds it and degrades to the bare `textToImage` key if it is
|
||||||
// anything else.
|
// anything else.
|
||||||
generationType: resolveBlockGenerationType(textToImageBody, {
|
//
|
||||||
imageWorkflowType: generateInput.workflow,
|
// 🔴 RESOLVED ONCE, ABOVE THE WHATIF QUOTE, and read here. The fee
|
||||||
}),
|
// quote, the fee charge and this row must agree on the key or the fee
|
||||||
|
// is priced under one generation type and recorded under another.
|
||||||
|
generationType: blockGenerationType,
|
||||||
// BASE generation cost, for the DARK per-generation author-fee
|
// BASE generation cost, for the DARK per-generation author-fee
|
||||||
// observation only (never persisted). 🔴 `.base`, NOT `.total` and
|
// observation only (never persisted). 🔴 `.base`, NOT `.total` and
|
||||||
// NOT `buzzAmount` above: `total` already carries the per-resource
|
// NOT `buzzAmount` above: `total` already carries the per-resource
|
||||||
@@ -8277,20 +8377,45 @@ async function submitCustomComfyWorkflow(opts: {
|
|||||||
// no generation to attribute), mirroring the txt2img guard.
|
// no generation to attribute), mirroring the txt2img guard.
|
||||||
const spendWorkflowId = snapshot.workflowId;
|
const spendWorkflowId = snapshot.workflowId;
|
||||||
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
||||||
|
const spendBasis = deriveBlockSpendBasis(
|
||||||
|
realizedTransactions,
|
||||||
|
isGreen,
|
||||||
|
// Fall back to the realized snapshot cost (then the reserved ceiling)
|
||||||
|
// when no paid debit is surfaced — the conservative FREE floor, which
|
||||||
|
// isPayoutEligibleBuzz EXCLUDES → zero payable basis (anti-farming
|
||||||
|
// preserved). "Bounty" here was the removed platform-funded rail; the
|
||||||
|
// exclusion still holds, it just bounds the recorded basis now.
|
||||||
|
snapshot.cost?.total ?? ceiling
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔴 `reservedAuthorFeeBuzz: 0`, AND IT IS A FACT ABOUT THIS PATH, NOT A
|
||||||
|
// PLACEHOLDER. customComfy is POST-PAID: it takes no whatIf quote at all —
|
||||||
|
// its ceiling IS the app's declared `maxBuzz`, stamped as the step timeout
|
||||||
|
// the orchestrator physically enforces — so there is no pre-submit
|
||||||
|
// `cost.base` to price a fee from and nothing was reserved for one. The
|
||||||
|
// charge clamps to what was reserved, so passing 0 makes charging here
|
||||||
|
// structurally impossible rather than merely unlikely. The call is made
|
||||||
|
// anyway so that all four submit paths route their author fee through ONE
|
||||||
|
// function: a path that later gains a pre-submit base changes this argument
|
||||||
|
// instead of re-deriving the rule, and the seam guard can enumerate the
|
||||||
|
// population.
|
||||||
|
await chargeBlockAuthorFee({
|
||||||
|
workflowId: spendWorkflowId,
|
||||||
|
appId: claims.appId,
|
||||||
|
appBlockId: claims.appBlockId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
buzzType: spendBasis.buzzType,
|
||||||
|
baseGenerationBuzz: realizedBaseCost,
|
||||||
|
priceIsCap: realizedPriceIsCap,
|
||||||
|
generationType: resolveBlockGenerationType(body),
|
||||||
|
reservedAuthorFeeBuzz: 0,
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { recordSpendAttribution } = await import(
|
const { recordSpendAttribution } = await import(
|
||||||
'~/server/services/blocks/buzz-attribution.service'
|
'~/server/services/blocks/buzz-attribution.service'
|
||||||
);
|
);
|
||||||
const { buzzType, buzzAmount } = deriveBlockSpendBasis(
|
const { buzzType, buzzAmount } = spendBasis;
|
||||||
realizedTransactions,
|
|
||||||
isGreen,
|
|
||||||
// Fall back to the realized snapshot cost (then the reserved ceiling)
|
|
||||||
// when no paid debit is surfaced — the conservative FREE floor, which
|
|
||||||
// isPayoutEligibleBuzz EXCLUDES → zero payable basis (anti-farming
|
|
||||||
// preserved). "Bounty" here was the removed platform-funded rail; the
|
|
||||||
// exclusion still holds, it just bounds the recorded basis now.
|
|
||||||
snapshot.cost?.total ?? ceiling
|
|
||||||
);
|
|
||||||
await recordSpendAttribution({
|
await recordSpendAttribution({
|
||||||
userId,
|
userId,
|
||||||
buzzAmount,
|
buzzAmount,
|
||||||
@@ -9043,7 +9168,29 @@ async function submitStepWorkflow(opts: {
|
|||||||
// `max(Math.ceil(plan.reserveBuzz), quoted)` — so the two paths agree by
|
// `max(Math.ceil(plan.reserveBuzz), quoted)` — so the two paths agree by
|
||||||
// construction rather than by the block having been shown this number. See
|
// construction rather than by the block having been shown this number. See
|
||||||
// `estimateStepWorkflow`.
|
// `estimateStepWorkflow`.
|
||||||
const reserveBuzz = Math.max(declaredBuzz, quotedBuzz);
|
// 🔴 THE AUTHOR FEE IS PRICED IN BEFORE THE GATE AND BEFORE EVERY RESERVATION,
|
||||||
|
// exactly as on the txt2img path — see the long note there. `reserveBuzz` is
|
||||||
|
// the number the per-call budget gate, the per-user cap, the consent budget,
|
||||||
|
// the per-app aggregate cap and the dev-session backstop are all taken against,
|
||||||
|
// so the fee has to be inside it or it escapes all five.
|
||||||
|
const authorFeeQuote = await quoteBlockAuthorFee({
|
||||||
|
baseGenerationBuzz: whatIfResult.cost?.base,
|
||||||
|
priceIsCap: whatIfResult.cost?.variable,
|
||||||
|
generationType: step.id,
|
||||||
|
appId: claims.appId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
workflowLabel: blockExternalId,
|
||||||
|
});
|
||||||
|
const reservedAuthorFeeBuzz = authorFeeQuote.charge ? authorFeeQuote.feeBuzz : 0;
|
||||||
|
// 🔴 TWO NUMBERS, BECAUSE THE OVERAGE CORRECTION BELOW COMPARES AGAINST ONE OF
|
||||||
|
// THEM AND NOT THE OTHER. `reserveGenerationBuzz` is the GENERATION price every
|
||||||
|
// cap was short against if the orchestrator bills more than it quoted;
|
||||||
|
// `reserveBuzz` is what is actually reserved. The fee leg can never be short —
|
||||||
|
// `chargeBlockAuthorFee` clamps the charge to what was reserved — so folding it
|
||||||
|
// into the overage comparison would shrink every measured overage by the fee
|
||||||
|
// and leave the counters genuinely under-corrected.
|
||||||
|
const reserveGenerationBuzz = Math.max(declaredBuzz, quotedBuzz);
|
||||||
|
const reserveBuzz = reserveGenerationBuzz + reservedAuthorFeeBuzz;
|
||||||
|
|
||||||
// (1) Pre-submit gate against the token's per-call budget — now enforced
|
// (1) Pre-submit gate against the token's per-call budget — now enforced
|
||||||
// against the ORCHESTRATOR'S OWN NUMBER, not a declared constant.
|
// against the ORCHESTRATOR'S OWN NUMBER, not a declared constant.
|
||||||
@@ -9323,7 +9470,12 @@ async function submitStepWorkflow(opts: {
|
|||||||
// saturated line, exactly as declared-price drift used to be invisible
|
// saturated line, exactly as declared-price drift used to be invisible
|
||||||
// inside `exact`. `over_reserved` is the one to alert on; `over` is a
|
// inside `exact`. `over_reserved` is the one to alert on; `over` is a
|
||||||
// report that a declared constant does not describe reality.
|
// report that a declared constant does not describe reality.
|
||||||
const capOverage = billed - reserveBuzz;
|
// 🔴 AGAINST `reserveGenerationBuzz`, NOT `reserveBuzz`. `billed` is the
|
||||||
|
// orchestrator's GENERATION cost; `reserveBuzz` also carries the author-fee
|
||||||
|
// leg, which is charged at exactly the reserved amount and can never leave a
|
||||||
|
// counter short. Comparing against the fee-inclusive number would understate
|
||||||
|
// every real overage by the fee — the one direction a cap must not drift.
|
||||||
|
const capOverage = billed - reserveGenerationBuzz;
|
||||||
const priceOverage = billed - declaredBuzz;
|
const priceOverage = billed - declaredBuzz;
|
||||||
recordStepPriceCheck(
|
recordStepPriceCheck(
|
||||||
step.id,
|
step.id,
|
||||||
@@ -9520,15 +9672,35 @@ async function submitStepWorkflow(opts: {
|
|||||||
|
|
||||||
const spendWorkflowId = snapshot.workflowId;
|
const spendWorkflowId = snapshot.workflowId;
|
||||||
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
||||||
|
const spendBasis = deriveBlockSpendBasis(
|
||||||
|
realizedTransactions,
|
||||||
|
isGreen,
|
||||||
|
// 🔴 `reserveGenerationBuzz`, not `reserveBuzz`. The attribution row records
|
||||||
|
// what the platform took for the GENERATION; the reservation also carries
|
||||||
|
// the author-fee leg, which is a separate charge with its own row.
|
||||||
|
snapshot.cost?.total ?? reserveGenerationBuzz
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔴 THE VIEWER-FACING DEBIT, AWAITED, CLAMPED TO WHAT WAS RESERVED ABOVE.
|
||||||
|
// See the txt2img path for the full reasoning; this is the same call with
|
||||||
|
// this path's own quote.
|
||||||
|
await chargeBlockAuthorFee({
|
||||||
|
workflowId: spendWorkflowId,
|
||||||
|
appId: claims.appId,
|
||||||
|
appBlockId: claims.appBlockId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
buzzType: spendBasis.buzzType,
|
||||||
|
baseGenerationBuzz: realizedBaseCost,
|
||||||
|
priceIsCap: realizedPriceIsCap,
|
||||||
|
generationType: step.id,
|
||||||
|
reservedAuthorFeeBuzz,
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { recordSpendAttribution } = await import(
|
const { recordSpendAttribution } = await import(
|
||||||
'~/server/services/blocks/buzz-attribution.service'
|
'~/server/services/blocks/buzz-attribution.service'
|
||||||
);
|
);
|
||||||
const { buzzType, buzzAmount } = deriveBlockSpendBasis(
|
const { buzzType, buzzAmount } = spendBasis;
|
||||||
realizedTransactions,
|
|
||||||
isGreen,
|
|
||||||
snapshot.cost?.total ?? reserveBuzz
|
|
||||||
);
|
|
||||||
await recordSpendAttribution({
|
await recordSpendAttribution({
|
||||||
userId,
|
userId,
|
||||||
buzzAmount,
|
buzzAmount,
|
||||||
@@ -10102,15 +10274,37 @@ async function submitPassThroughStepWorkflow(opts: {
|
|||||||
|
|
||||||
const spendWorkflowId = snapshot.workflowId;
|
const spendWorkflowId = snapshot.workflowId;
|
||||||
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
if (spendWorkflowId && spendWorkflowId !== 'failed' && snapshot.status !== 'failed') {
|
||||||
|
const spendBasis = deriveBlockSpendBasis(
|
||||||
|
realizedTransactions,
|
||||||
|
isGreen,
|
||||||
|
snapshot.cost?.total ?? ceiling
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔴 `reservedAuthorFeeBuzz: 0`, AND IT IS A FACT ABOUT THIS PATH. Like
|
||||||
|
// customComfy, this arm is POST-PAID and reserves a CEILING. Its pre-submit
|
||||||
|
// quote goes through `quotePassThroughStepBuzz`, which returns `cost.total`
|
||||||
|
// and nothing else — there is no `cost.base` at reservation time to price a
|
||||||
|
// fee from, so nothing was reserved for one and the clamp in
|
||||||
|
// `chargeBlockAuthorFee` makes charging here impossible. Widening that helper
|
||||||
|
// to surface a base is what would change this argument; the call is here so
|
||||||
|
// the population of submit paths stays closed in the meantime.
|
||||||
|
await chargeBlockAuthorFee({
|
||||||
|
workflowId: spendWorkflowId,
|
||||||
|
appId: claims.appId,
|
||||||
|
appBlockId: claims.appBlockId,
|
||||||
|
viewerUserId: userId,
|
||||||
|
buzzType: spendBasis.buzzType,
|
||||||
|
baseGenerationBuzz: realizedBaseCost,
|
||||||
|
priceIsCap: realizedPriceIsCap,
|
||||||
|
generationType: resolveBlockGenerationType(body),
|
||||||
|
reservedAuthorFeeBuzz: 0,
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { recordSpendAttribution } = await import(
|
const { recordSpendAttribution } = await import(
|
||||||
'~/server/services/blocks/buzz-attribution.service'
|
'~/server/services/blocks/buzz-attribution.service'
|
||||||
);
|
);
|
||||||
const { buzzType, buzzAmount } = deriveBlockSpendBasis(
|
const { buzzType, buzzAmount } = spendBasis;
|
||||||
realizedTransactions,
|
|
||||||
isGreen,
|
|
||||||
snapshot.cost?.total ?? ceiling
|
|
||||||
);
|
|
||||||
await recordSpendAttribution({
|
await recordSpendAttribution({
|
||||||
userId,
|
userId,
|
||||||
buzzAmount,
|
buzzAmount,
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ const ROUTER = path.join(process.cwd(), 'src/server/routers/blocks.router.ts');
|
|||||||
|
|
||||||
/** Every `recordSpendAttribution({ … })` argument object in the router source. */
|
/** Every `recordSpendAttribution({ … })` argument object in the router source. */
|
||||||
function spendAttributionCallSites(source: string): string[] {
|
function spendAttributionCallSites(source: string): string[] {
|
||||||
|
return callSites(source, 'recordSpendAttribution({');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `<fn>({ … })` argument object in the router source, braces balanced. */
|
||||||
|
function callSites(source: string, opener: string): string[] {
|
||||||
const sites: string[] = [];
|
const sites: string[] = [];
|
||||||
const opener = 'recordSpendAttribution({';
|
|
||||||
let from = 0;
|
let from = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const start = source.indexOf(opener, from);
|
const start = source.indexOf(opener, from);
|
||||||
@@ -156,3 +160,143 @@ describe('author fee — the spend-attribution seam', () => {
|
|||||||
expect(source).not.toMatch(/realizedPriceIsCap\s*=\s*[^;]*snapshot\./);
|
expect(source).not.toMatch(/realizedPriceIsCap\s*=\s*[^;]*snapshot\./);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SLICE-2b HALF OF THE SEAM — the viewer-charge path.
|
||||||
|
*
|
||||||
|
* 🔴 EVERY ASSERTION IN THIS BLOCK IS RED AT `dce428a492` (the merge base): the
|
||||||
|
* router contains ZERO `chargeBlockAuthorFee` call sites there, so the extractor
|
||||||
|
* returns an empty array and the positive control fails first. It is regression
|
||||||
|
* coverage in the strict sense — it pins a property this change introduces and
|
||||||
|
* would catch its removal.
|
||||||
|
*
|
||||||
|
* 🔴 WHAT IT PINS AND WHY A UNIT TEST CANNOT. The one safety hole the design
|
||||||
|
* review found is a fee DEBITED OUTSIDE THE RESERVATION. That is not a property
|
||||||
|
* of any function — `chargeBlockAuthorFee` in isolation is correct either way.
|
||||||
|
* It is a property of the ORDER of statements in a ~10,000-line tRPC router
|
||||||
|
* whose handlers cannot be invoked without the whole orchestrator + auth stack.
|
||||||
|
* So it is pinned as source text: the number every gate and every reservation is
|
||||||
|
* taken against must LITERALLY be the generation price PLUS the quoted fee, and
|
||||||
|
* every submit path must hand the charge the amount it reserved.
|
||||||
|
*
|
||||||
|
* The behavioural half lives in
|
||||||
|
* `src/server/services/blocks/__tests__/author-fee-charge.service.test.ts` — a
|
||||||
|
* structural check alone would type-check past a wrong argument, and a
|
||||||
|
* behavioural check alone cannot see a path that forgot to call at all.
|
||||||
|
*/
|
||||||
|
describe('author fee — the viewer-charge seam', () => {
|
||||||
|
const source = readFileSync(ROUTER, 'utf8');
|
||||||
|
const charges = callSites(source, 'chargeBlockAuthorFee({');
|
||||||
|
const quotes = callSites(source, 'quoteBlockAuthorFee({');
|
||||||
|
|
||||||
|
it('the extractor finds charge and quote call sites (positive control)', () => {
|
||||||
|
// Without this, an extractor that silently matched nothing would make every
|
||||||
|
// assertion below vacuously true over two empty arrays.
|
||||||
|
expect(charges.length).toBeGreaterThan(0);
|
||||||
|
expect(quotes.length).toBeGreaterThan(0);
|
||||||
|
expect(charges[0]).toContain('workflowId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('there are exactly FOUR charge call sites — one per submit path', () => {
|
||||||
|
// textToImage, customComfy, the registry-step bridge, and the pass-through
|
||||||
|
// step. A new submit path is a deliberate decision about whether it charges
|
||||||
|
// an author fee, so it must land here rather than silently inherit a skip.
|
||||||
|
expect(charges).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 every charge site passes the amount ITS OWN path reserved', () => {
|
||||||
|
// The ceiling is what makes "priced into the reservation" structural rather
|
||||||
|
// than conventional: a path that reserved nothing passes 0 and can then
|
||||||
|
// charge nothing, whatever the realized base says.
|
||||||
|
for (const site of charges) expect(site).toContain('reservedAuthorFeeBuzz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 exactly TWO paths reserve a fee and exactly TWO reserve none', () => {
|
||||||
|
// The two that reserve none are POST-PAID and take no pre-submit `cost.base`
|
||||||
|
// to price from (customComfy makes no whatIf quote at all; the pass-through
|
||||||
|
// quote helper returns a total only). Asserting the SPLIT rather than just
|
||||||
|
// the total is what makes a silent regression visible in either direction: a
|
||||||
|
// priced path degraded to 0 stops charging, and a post-paid path handed a
|
||||||
|
// live reserve starts charging off a ceiling.
|
||||||
|
const zeroed = charges.filter((s) => /reservedAuthorFeeBuzz:\s*0\b/.test(s));
|
||||||
|
const priced = charges.filter((s) => /reservedAuthorFeeBuzz,/.test(s));
|
||||||
|
expect(zeroed).toHaveLength(2);
|
||||||
|
expect(priced).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every charge site prices off the RAW orchestrator response, never `snapshot`', () => {
|
||||||
|
// Same rule, same reason, as the attribution seam above: `snapshot.cost` is
|
||||||
|
// `{ total }` only, so a `snapshot.cost?.base` here is `undefined` silently —
|
||||||
|
// and `undefined` maps to a `base-unavailable` skip, i.e. the fee quietly
|
||||||
|
// stops charging on every generation with nothing to say so.
|
||||||
|
for (const site of charges) {
|
||||||
|
expect(site).toContain('baseGenerationBuzz: realizedBaseCost');
|
||||||
|
expect(site).toContain('priceIsCap: realizedPriceIsCap');
|
||||||
|
expect(site).not.toMatch(/baseGenerationBuzz:\s*(snapshot|buzzAmount|cost\b|\d)/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 THE FEE IS INSIDE THE RESERVED NUMBER, on both priced paths', () => {
|
||||||
|
// The whole point of the slice, as source text. `cost` (txt2img) and
|
||||||
|
// `reserveBuzz` (registry step) are the numbers the per-call `buzzBudget`
|
||||||
|
// gate, the per-user daily cap, the viewer's OWN per-app CONSENT BUDGET, the
|
||||||
|
// per-app aggregate cap and the dev-session backstop are each taken against.
|
||||||
|
// Delete either `+ reservedAuthorFeeBuzz` and the fee escapes all five while
|
||||||
|
// every other test in this repo stays green.
|
||||||
|
const folded = source.match(/=\s*\w+\s*\+\s*reservedAuthorFeeBuzz;/g);
|
||||||
|
expect(folded).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 both quotes are priced off the WHATIF response, before anything is reserved', () => {
|
||||||
|
expect(quotes).toHaveLength(2);
|
||||||
|
for (const site of quotes) {
|
||||||
|
expect(site).toContain('baseGenerationBuzz: whatIfResult.cost?.base');
|
||||||
|
expect(site).toContain('priceIsCap: whatIfResult.cost?.variable');
|
||||||
|
}
|
||||||
|
// ORDERING, on the txt2img path: price → reserve → charge. A quote taken
|
||||||
|
// after the reservation would be a number nothing was gated on, which is the
|
||||||
|
// defect this whole block exists to make impossible.
|
||||||
|
const firstQuote = source.indexOf('quoteBlockAuthorFee({');
|
||||||
|
const firstReserve = source.indexOf('reserveAppSpend(');
|
||||||
|
const firstCharge = source.indexOf('chargeBlockAuthorFee({');
|
||||||
|
expect(firstQuote).toBeGreaterThan(-1);
|
||||||
|
expect(firstQuote).toBeLessThan(firstReserve);
|
||||||
|
expect(firstReserve).toBeLessThan(firstCharge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 the fee is REVERSED where the generation reaches a non-succeeded terminal state', () => {
|
||||||
|
// Two observers reach a terminal workflow: `pollWorkflow` (every poll after
|
||||||
|
// the workflow settles) and `cancelAppWorkflow`. Both must reverse, or a
|
||||||
|
// refunded generation leaves an accrual standing and the author is paid out
|
||||||
|
// of money the viewer got back. Pinned as a COUNT so removing one is visible.
|
||||||
|
const reversals = callSites(source, 'reverseBlockAuthorFee({');
|
||||||
|
expect(reversals).toHaveLength(2);
|
||||||
|
for (const site of reversals) {
|
||||||
|
expect(site).toContain('workflowId: input.workflowId');
|
||||||
|
expect(site).toContain('terminalStatus: snapshot.status');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 neither reversal fires on a SUCCEEDED workflow', () => {
|
||||||
|
// The direction of this reversal that costs the AUTHOR rather than
|
||||||
|
// protecting the viewer. `cancelAppWorkflow` is where it bites: a cancel
|
||||||
|
// RACES completion, so the re-read after `cancelWorkflow` can report
|
||||||
|
// `succeeded` — the viewer got their generation, the orchestrator refunds
|
||||||
|
// nothing, and a reversal would hand back money for delivered work.
|
||||||
|
//
|
||||||
|
// Pinned on the text immediately PRECEDING each call rather than on a bare
|
||||||
|
// substring count, so a guard that exists somewhere else in the file cannot
|
||||||
|
// satisfy it.
|
||||||
|
let from = 0;
|
||||||
|
let guarded = 0;
|
||||||
|
for (;;) {
|
||||||
|
const at = source.indexOf('reverseBlockAuthorFee({', from);
|
||||||
|
if (at === -1) break;
|
||||||
|
if (source.slice(Math.max(0, at - 200), at).includes("snapshot.status !== 'succeeded'")) {
|
||||||
|
guarded += 1;
|
||||||
|
}
|
||||||
|
from = at + 1;
|
||||||
|
}
|
||||||
|
expect(guarded).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the author-fee VIEWER-CHARGE path (slice 2b) — quote, debit,
|
||||||
|
* accrue, reverse.
|
||||||
|
*
|
||||||
|
* ── WHAT KIND OF COVERAGE THIS IS, STATED HONESTLY ──────────────────────────
|
||||||
|
* `author-fee-charge.service.ts` is a NEW module, so nothing in this file could
|
||||||
|
* have been RED at `origin/main` for any reason except the import failing. These
|
||||||
|
* are NEW-FEATURE guards, not regression guards, and they must not be counted as
|
||||||
|
* the latter. The genuinely red-at-base guard for this change is
|
||||||
|
* `src/server/services/__tests__/no-divergent-author-fee-base.test.ts`, which
|
||||||
|
* enumerates the router call sites and finds ZERO charge sites at the base.
|
||||||
|
*
|
||||||
|
* What makes the guards below real is the MUTATION SWEEP recorded in the PR
|
||||||
|
* body: each rule was broken on purpose and the named test went red with its own
|
||||||
|
* assertion.
|
||||||
|
*
|
||||||
|
* ── THE PROPERTIES WORTH PINNING ────────────────────────────────────────────
|
||||||
|
* Every one of them fails SILENTLY and FINANCIALLY:
|
||||||
|
*
|
||||||
|
* - the fee can never exceed what the caller reserved, so it can never escape
|
||||||
|
* the viewer's per-call budget, daily cap or per-app CONSENT BUDGET
|
||||||
|
* - a path that reserved nothing charges nothing, whatever the realized base says
|
||||||
|
* - self-dealing is refused BEFORE the debit, not after it — slice 2a's
|
||||||
|
* exclusion ran only on the accrual, so a charge path that skipped it would
|
||||||
|
* take the money and then decline to owe anyone
|
||||||
|
* - a debit is reconciled BY COUNT: `createBuzzTransactionMany` drops an
|
||||||
|
* `insufficientFunds` result from BOTH arrays without throwing, so "it did
|
||||||
|
* not throw" says nothing about whether money moved
|
||||||
|
* - a landed debit whose accrual failed is REFUNDED, or the platform silently
|
||||||
|
* keeps money it is only a conduit for (D1)
|
||||||
|
* - a reversal deletes only an UNSETTLED row, and only the caller whose
|
||||||
|
* status-guarded DELETE matched issues the refund
|
||||||
|
*
|
||||||
|
* ── WHAT IS AND IS NOT MOCKED ───────────────────────────────────────────────
|
||||||
|
* The Buzz service, the Flipt flag, Prisma and the logger are mocked at the
|
||||||
|
* module boundary. `accrueBlockAuthorFee` is NOT mocked, deliberately: the
|
||||||
|
* expensive defects on this path live in the SEAM between the charge and the
|
||||||
|
* accrual (a charge that self-deals, an accrual failure nobody refunds), and a
|
||||||
|
* mocked accrual cannot express either. That also means Prisma's `oauthClient`
|
||||||
|
* mock is what drives the self-dealing arms end to end.
|
||||||
|
*
|
||||||
|
* 🔴 PRISMA IS MOCKED AT THE MODULE BOUNDARY, SO A GREEN RUN HERE IS NOT A CLAIM
|
||||||
|
* THAT ANY OF THIS WORKS AGAINST A REAL DATABASE. No test in this file has ever
|
||||||
|
* touched `block_author_fee_accrual`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { mockLog, mockCreateMany, mockFlag } = vi.hoisted(() => ({
|
||||||
|
mockLog: vi.fn(),
|
||||||
|
mockCreateMany: vi.fn(),
|
||||||
|
mockFlag: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('~/server/services/buzz.service', () => ({
|
||||||
|
createBuzzTransactionMany: (...args: unknown[]) => mockCreateMany(...args),
|
||||||
|
}));
|
||||||
|
vi.mock('~/server/services/app-blocks-flag', () => ({
|
||||||
|
isAppBlocksAuthorFeeEnabled: () => mockFlag(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
blockAuthorFeeChargeKey,
|
||||||
|
blockAuthorFeeReversalKey,
|
||||||
|
chargeBlockAuthorFee,
|
||||||
|
quoteBlockAuthorFee,
|
||||||
|
reverseBlockAuthorFee,
|
||||||
|
} from '../author-fee-charge.service';
|
||||||
|
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||||
|
import { loggingMock } from '~/__tests__/mocks/logging.mock';
|
||||||
|
const mockDbRead = dbMock.dbRead;
|
||||||
|
const mockDbWrite = dbMock.dbWrite;
|
||||||
|
loggingMock.logToAxiom.mockImplementation((...args: unknown[]) => {
|
||||||
|
mockLog(...args);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const APP_ID = 'app_charge';
|
||||||
|
const APP_BLOCK_ID = 'apb_charge';
|
||||||
|
const OWNER_ID = 941;
|
||||||
|
const VIEWER_ID = 137;
|
||||||
|
const WORKFLOW_ID = 'wf_charge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base that makes the fee UNAMBIGUOUS under the platform config
|
||||||
|
* (1 ⚡ flat / 5% of base): `floor(400 × 500 / 10000) = 20`, so the percentage
|
||||||
|
* leg governs and 20 shares no value with the flat leg, the default percentage,
|
||||||
|
* the basis-point scale, or any reservation figure used below. A mutant that
|
||||||
|
* reaches for a constant instead of the computed fee cannot land on 20.
|
||||||
|
*/
|
||||||
|
const BASE_BUZZ = 400;
|
||||||
|
const EXPECTED_FEE = 20;
|
||||||
|
|
||||||
|
/** Every `...Once` queue in this suite, reset explicitly. */
|
||||||
|
function resetOnceQueues() {
|
||||||
|
mockCreateMany.mockReset();
|
||||||
|
mockFlag.mockReset();
|
||||||
|
mockDbRead.oauthClient.findUnique.mockReset();
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.create.mockReset();
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockReset();
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.deleteMany.mockReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// 🔴 clearAllMocks CLEARS CALL HISTORY BUT NOT `mockResolvedValueOnce` QUEUES —
|
||||||
|
// those are implementations, and only a reset drops them. Every mock this suite
|
||||||
|
// feeds with `...Once` is reset above, or a queue left over by one test is
|
||||||
|
// consumed by the next and the suite becomes order-dependent.
|
||||||
|
resetOnceQueues();
|
||||||
|
mockFlag.mockResolvedValue(true);
|
||||||
|
mockDbRead.oauthClient.findUnique.mockResolvedValue({ id: APP_ID, userId: OWNER_ID });
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.create.mockResolvedValue({});
|
||||||
|
// 🔴 THE FAKE MATCHES THE REAL CONTRACT. `createBuzzTransactionMany` returns
|
||||||
|
// `{ transactions, conflicts }` and does NOT throw on a per-transaction
|
||||||
|
// failure — a dropped transaction is simply absent from both arrays. A fake
|
||||||
|
// that resolved to `undefined` could express neither a conflict nor a drop,
|
||||||
|
// which is structurally why those are the two arms worth testing.
|
||||||
|
mockCreateMany.mockImplementation(async (txs: unknown[]) => ({
|
||||||
|
transactions: txs.map((_, i) => ({ id: `tx_${i}` })),
|
||||||
|
conflicts: [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function chargeArgs(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
appId: APP_ID,
|
||||||
|
appBlockId: APP_BLOCK_ID,
|
||||||
|
viewerUserId: VIEWER_ID,
|
||||||
|
buzzType: 'yellow' as const,
|
||||||
|
baseGenerationBuzz: BASE_BUZZ,
|
||||||
|
priceIsCap: false,
|
||||||
|
generationType: 'textToImage:txt2img',
|
||||||
|
reservedAuthorFeeBuzz: EXPECTED_FEE,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteArgs(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
baseGenerationBuzz: BASE_BUZZ,
|
||||||
|
priceIsCap: false,
|
||||||
|
generationType: 'textToImage:txt2img',
|
||||||
|
appId: APP_ID,
|
||||||
|
viewerUserId: VIEWER_ID,
|
||||||
|
workflowLabel: WORKFLOW_ID,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one debit transaction the charge path submits, or undefined. */
|
||||||
|
function debitTx() {
|
||||||
|
const call = mockCreateMany.mock.calls.find(
|
||||||
|
(c) => (c[0] as { externalTransactionId: string }[])[0].fromAccountId !== 0
|
||||||
|
);
|
||||||
|
return call?.[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one refund transaction, or undefined. */
|
||||||
|
function refundTx() {
|
||||||
|
const call = mockCreateMany.mock.calls.find(
|
||||||
|
(c) => (c[0] as { fromAccountId: number }[])[0].fromAccountId === 0
|
||||||
|
);
|
||||||
|
return call?.[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('quoteBlockAuthorFee — pricing the fee INTO the reservation', () => {
|
||||||
|
it('prices the fee off the whatIf BASE and returns the payee', async () => {
|
||||||
|
const quote = await quoteBlockAuthorFee(quoteArgs());
|
||||||
|
// 20 is computed by hand from the platform rule: max(1, floor(400 × 5%)).
|
||||||
|
expect(quote).toEqual({
|
||||||
|
charge: true,
|
||||||
|
feeBuzz: EXPECTED_FEE,
|
||||||
|
appOwnerUserId: OWNER_ID,
|
||||||
|
computation: expect.objectContaining({ feeBuzz: EXPECTED_FEE, baseGenerationBuzz: 400 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 is DARK behind the flag, and reads it before the database', async () => {
|
||||||
|
mockFlag.mockResolvedValue(false);
|
||||||
|
const quote = await quoteBlockAuthorFee(quoteArgs());
|
||||||
|
expect(quote).toEqual({ charge: false, reason: 'flag-disabled' });
|
||||||
|
// The ordering half: with the flag off nothing may cost a query. A copy of
|
||||||
|
// the pricing hoisted above the flag read would leave this call behind.
|
||||||
|
expect(mockDbRead.oauthClient.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a flag read that throws is not permission to charge anyone', async () => {
|
||||||
|
mockFlag.mockRejectedValue(new Error('flipt unreachable'));
|
||||||
|
expect(await quoteBlockAuthorFee(quoteArgs())).toEqual({
|
||||||
|
charge: false,
|
||||||
|
reason: 'flag-disabled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a CAP-priced generation, and does so before it looks at the base', async () => {
|
||||||
|
// Both provisional: a cap price AND no base. `price-is-cap` must win, or the
|
||||||
|
// recoverable `base-unavailable` population acquires a silent bias.
|
||||||
|
const quote = await quoteBlockAuthorFee(
|
||||||
|
quoteArgs({ priceIsCap: true, baseGenerationBuzz: null })
|
||||||
|
);
|
||||||
|
expect(quote).toEqual({ charge: false, reason: 'price-is-cap' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the orchestrator surfaced no base', async () => {
|
||||||
|
expect(await quoteBlockAuthorFee(quoteArgs({ baseGenerationBuzz: null }))).toEqual({
|
||||||
|
charge: false,
|
||||||
|
reason: 'base-unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a zero fee is the ABSENCE of a charge, and costs no payee query', async () => {
|
||||||
|
// `chat-completion` is 0/0 in the platform table.
|
||||||
|
const quote = await quoteBlockAuthorFee(quoteArgs({ generationType: 'chat-completion' }));
|
||||||
|
expect(quote).toEqual({ charge: false, reason: 'zero-fee' });
|
||||||
|
expect(mockDbRead.oauthClient.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 refuses SELF-DEALING at quote time, so no fee is ever reserved for it', async () => {
|
||||||
|
const quote = await quoteBlockAuthorFee(quoteArgs({ viewerUserId: OWNER_ID }));
|
||||||
|
expect(quote).toEqual({ charge: false, reason: 'self-dealing' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the app or its owner cannot be resolved', async () => {
|
||||||
|
mockDbRead.oauthClient.findUnique.mockResolvedValue(null);
|
||||||
|
expect(await quoteBlockAuthorFee(quoteArgs())).toEqual({
|
||||||
|
charge: false,
|
||||||
|
reason: 'app-missing',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to no-fee rather than throwing when the payee lookup fails', async () => {
|
||||||
|
mockDbRead.oauthClient.findUnique.mockRejectedValue(new Error('connection lost'));
|
||||||
|
expect(await quoteBlockAuthorFee(quoteArgs())).toEqual({ charge: false, reason: 'error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chargeBlockAuthorFee — the debit', () => {
|
||||||
|
it('debits the viewer, credits the platform conduit, and writes the accrual', async () => {
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
|
||||||
|
expect(result).toEqual({ charged: true, feeBuzz: EXPECTED_FEE, accrualId: expect.any(String) });
|
||||||
|
const tx = debitTx();
|
||||||
|
expect(tx.fromAccountId).toBe(VIEWER_ID);
|
||||||
|
expect(tx.toAccountId).toBe(0);
|
||||||
|
expect(tx.amount).toBe(EXPECTED_FEE);
|
||||||
|
// D6 — the account the generation drained IS the fee's currency, on both legs.
|
||||||
|
expect(tx.fromAccountType).toBe('yellow');
|
||||||
|
expect(tx.toAccountType).toBe('yellow');
|
||||||
|
expect(tx.externalTransactionId).toBe(blockAuthorFeeChargeKey(WORKFLOW_ID));
|
||||||
|
|
||||||
|
// D1 — the author is owed exactly what the viewer was debited.
|
||||||
|
const row = mockDbWrite.blockAuthorFeeAccrual.create.mock.calls[0][0].data;
|
||||||
|
expect(row.feeBuzz).toBe(EXPECTED_FEE);
|
||||||
|
expect(row.appOwnerUserId).toBe(OWNER_ID);
|
||||||
|
expect(row.viewerUserId).toBe(VIEWER_ID);
|
||||||
|
expect(row.buzzType).toBe('yellow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 a path that RESERVED NOTHING charges nothing, whatever the realized base says', async () => {
|
||||||
|
// The structural bound. `baseGenerationBuzz` here would price a 20 ⚡ fee.
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ reservedAuthorFeeBuzz: 0 }));
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'not-reserved' });
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
// Not even the flag is read — nothing below the bound may run.
|
||||||
|
expect(mockFlag).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 NEVER charges more than was reserved (the consent-budget escape)', async () => {
|
||||||
|
// Realized fee 20, reserved 6: the whatIf priced a cheaper generation than the
|
||||||
|
// submit realized. The viewer is billed 6, the number every gate was measured
|
||||||
|
// against. 6 is deliberately not a divisor of 20 and not any module constant.
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ reservedAuthorFeeBuzz: 6 }));
|
||||||
|
expect(result).toEqual({ charged: true, feeBuzz: 6, accrualId: expect.any(String) });
|
||||||
|
expect(debitTx().amount).toBe(6);
|
||||||
|
// The ROW carries the CHARGED number, or the settlement rail would mint the
|
||||||
|
// unclamped one and the author would be paid money the viewer never lost.
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.create.mock.calls[0][0].data.feeBuzz).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges the REALIZED fee when it is below the reservation', async () => {
|
||||||
|
// Reserved 50, realized 20 → the viewer keeps the difference. The clamp is a
|
||||||
|
// ceiling, not a floor: a mutant that used the reserve unconditionally bills 50.
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ reservedAuthorFeeBuzz: 50 }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
charged: true,
|
||||||
|
feeBuzz: EXPECTED_FEE,
|
||||||
|
accrualId: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(debitTx().amount).toBe(EXPECTED_FEE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 refuses SELF-DEALING BEFORE the debit, not after it', async () => {
|
||||||
|
// Slice 2a's exclusion lived only in the accrual writer, so a charge path that
|
||||||
|
// did not consult it would take the money and then decline to owe anyone.
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ viewerUserId: OWNER_ID }));
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'self-dealing' });
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not charge a cap-priced generation', async () => {
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ priceIsCap: true }));
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'price-is-cap' });
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not charge when the realized response carried no base', async () => {
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs({ baseGenerationBuzz: null }));
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'base-unavailable' });
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 a DROPPED debit is a failure, and it neither throws nor appears in either array', async () => {
|
||||||
|
// The insufficient-funds shape: the client reports it by OMISSION. A reader
|
||||||
|
// that trusted "it did not throw" would accrue a fee nobody paid.
|
||||||
|
mockCreateMany.mockResolvedValue({ transactions: [], conflicts: [] });
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'debit-failed' });
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a CONFLICT means the money already moved — it accrues rather than failing', async () => {
|
||||||
|
mockCreateMany.mockResolvedValue({ transactions: [], conflicts: [{ id: 'dup' }] });
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
expect(result).toEqual({ charged: true, feeBuzz: EXPECTED_FEE, accrualId: expect.any(String) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a throwing debit charges nothing and does not throw out', async () => {
|
||||||
|
mockCreateMany.mockRejectedValue(new Error('buzz service down'));
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'debit-failed' });
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 REFUNDS the viewer when the debit landed but the accrual did not', async () => {
|
||||||
|
// The one pair D1 forbids in both directions: the viewer paid and no row says
|
||||||
|
// who is owed it, so the platform keeps money it is only a conduit for.
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.create.mockRejectedValue(new Error('connection lost'));
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
expect(result).toEqual({ charged: false, reason: 'accrual-failed' });
|
||||||
|
|
||||||
|
const refund = refundTx();
|
||||||
|
expect(refund.fromAccountId).toBe(0);
|
||||||
|
expect(refund.toAccountId).toBe(VIEWER_ID);
|
||||||
|
expect(refund.amount).toBe(EXPECTED_FEE);
|
||||||
|
// 🔴 THE REVERSAL KEY, NOT A FRESH ONE — a terminal reversal of the same
|
||||||
|
// workflow later must CONFLICT here rather than refund a second time.
|
||||||
|
expect(refund.externalTransactionId).toBe(blockAuthorFeeReversalKey(WORKFLOW_ID));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a DUPLICATE accrual is the same state as a conflict — no refund', async () => {
|
||||||
|
const dup = Object.assign(new Error('P2002'), { code: 'P2002' });
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.create.mockRejectedValue(dup);
|
||||||
|
const result = await chargeBlockAuthorFee(chargeArgs());
|
||||||
|
expect(result).toEqual({ charged: true, feeBuzz: EXPECTED_FEE, accrualId: null });
|
||||||
|
expect(refundTx()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the charge key and the reversal key share no namespace', () => {
|
||||||
|
// A settlement key is `block-author-fee-<day>-<owner>-<currency>`; a bare
|
||||||
|
// `block-author-fee-` prefix here would put a workflow id and a settlement
|
||||||
|
// tuple in one namespace, where a collision moves money to the wrong side.
|
||||||
|
expect(blockAuthorFeeChargeKey('w')).toBe('block-author-fee-charge-w');
|
||||||
|
expect(blockAuthorFeeReversalKey('w')).toBe('block-author-fee-reversal-w');
|
||||||
|
expect(blockAuthorFeeChargeKey('w')).not.toBe(blockAuthorFeeReversalKey('w'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reverseBlockAuthorFee — the fee follows the refund', () => {
|
||||||
|
const accruedRow = {
|
||||||
|
id: 'bafa_1',
|
||||||
|
status: 'accrued',
|
||||||
|
viewerUserId: VIEWER_ID,
|
||||||
|
buzzType: 'blue',
|
||||||
|
feeBuzz: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('refunds the viewer and deletes the unsettled row', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue(accruedRow);
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ reversed: true, feeBuzz: 13 });
|
||||||
|
const refund = refundTx();
|
||||||
|
expect(refund.fromAccountId).toBe(0);
|
||||||
|
expect(refund.toAccountId).toBe(VIEWER_ID);
|
||||||
|
expect(refund.amount).toBe(13);
|
||||||
|
// D6 carried through the reversal too — a blue fee refunds as blue, or a
|
||||||
|
// failed generation would mint withdrawable Buzz out of free Buzz.
|
||||||
|
expect(refund.toAccountType).toBe('blue');
|
||||||
|
expect(refund.externalTransactionId).toBe(blockAuthorFeeReversalKey(WORKFLOW_ID));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 the DELETE is status-guarded, so a settled row cannot be swept up', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue(accruedRow);
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
await reverseBlockAuthorFee({ workflowId: WORKFLOW_ID, terminalStatus: 'failed' });
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.deleteMany.mock.calls[0][0].where).toEqual({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
status: 'accrued',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 refuses a SETTLED row — the money is already the author’s', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue({
|
||||||
|
...accruedRow,
|
||||||
|
status: 'settled',
|
||||||
|
});
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'canceled',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ reversed: false, reason: 'already-settled' });
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 only the caller whose DELETE matched refunds (concurrent terminal polls)', async () => {
|
||||||
|
// The row was read as `accrued` and then settled — or claimed by a concurrent
|
||||||
|
// poll — between the two statements. Without the count check BOTH callers
|
||||||
|
// would refund the same fee.
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue(accruedRow);
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'failed',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ reversed: false, reason: 'no-accrual' });
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops for a workflow that never accrued a fee', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue(null);
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'expired',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ reversed: false, reason: 'no-accrual' });
|
||||||
|
expect(mockDbWrite.blockAuthorFeeAccrual.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(mockCreateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('🔴 a DROPPED refund is reported, not swallowed as success', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockResolvedValue(accruedRow);
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
mockCreateMany.mockResolvedValue({ transactions: [], conflicts: [] });
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'failed',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ reversed: false, reason: 'refund-failed' });
|
||||||
|
// The row is already gone, so this log line is the ONLY recovery handle —
|
||||||
|
// it must carry the amount and the account.
|
||||||
|
const line = mockLog.mock.calls
|
||||||
|
.map((c) => c[0] as Record<string, unknown>)
|
||||||
|
.find((l) => l.message === 'fee refund did not land — viewer is still charged');
|
||||||
|
expect(line).toMatchObject({ viewerUserId: VIEWER_ID, feeBuzz: 13, buzzType: 'blue' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the lookup fails', async () => {
|
||||||
|
mockDbWrite.blockAuthorFeeAccrual.findUnique.mockRejectedValue(new Error('connection lost'));
|
||||||
|
const result = await reverseBlockAuthorFee({
|
||||||
|
workflowId: WORKFLOW_ID,
|
||||||
|
terminalStatus: 'failed',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ reversed: false, reason: 'refund-failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,17 +14,19 @@ import type { BlockAuthorFeeComputation } from './author-fee';
|
|||||||
// ── TWO HOPS, MIRRORING THE MODEL LICENSING FEE — THIS FILE IS HOP 1 ───────
|
// ── TWO HOPS, MIRRORING THE MODEL LICENSING FEE — THIS FILE IS HOP 1 ───────
|
||||||
// 1. AT SUBMIT the viewer is debited the fee, and one `accrued` row is written
|
// 1. AT SUBMIT the viewer is debited the fee, and one `accrued` row is written
|
||||||
// naming the app owner it is owed to. That debit is the CALLER's job — this
|
// naming the app owner it is owed to. That debit is the CALLER's job — this
|
||||||
// module only records it.
|
// module only records it. The caller is `chargeBlockAuthorFee` in
|
||||||
// 🔴 THAT CALLER DOES NOT EXIST YET. Nothing in this repo calls
|
// `author-fee-charge.service.ts` (slice 2b), which debits the viewer and
|
||||||
// `accrueBlockAuthorFee` today. An earlier revision of this comment named
|
// then awaits this function; it is the only production caller.
|
||||||
// `chargeBlockAuthorFee` "in the router" as though it were there. It never
|
// 2. DAILY the accrued rows are summed per (owner × buzz type × accrual day)
|
||||||
// was — the name appeared nowhere but in that sentence.
|
// and minted to the owner — `settleBlockAuthorFees` in
|
||||||
// 2. DAILY the accrued rows would be summed per (owner × buzz type) and minted
|
// `author-fee-settlement.service.ts`, driven by the
|
||||||
// to the owner. 🔴 THAT HOP IS NOT IN THIS SLICE. `settleBlockAuthorFees`
|
// `settle-block-author-fees` job.
|
||||||
// and its cron live on branch `zach/app-blocks-author-fee-slice2b`, held
|
//
|
||||||
// back because they have no consumer until hop 1 has a caller supplying
|
// 🔴 BOTH HOPS ARE STILL DARK. Every entry point is behind
|
||||||
// rows. So today this table accrues nothing and settles nothing, and the
|
// `app-blocks-author-fee-enabled`, which is `enabled: false`. The charge path
|
||||||
// only thing merging this slice changes is that the ledger EXISTS.
|
// reads that flag before it prices anything, so with the flag off no fee is
|
||||||
|
// quoted, no fee is reserved, no viewer is debited and this table stays empty —
|
||||||
|
// which is also why the settlement rail has nothing to settle.
|
||||||
//
|
//
|
||||||
// The two-hop shape is exactly what `deliver-creator-compensation` does for the
|
// The two-hop shape is exactly what `deliver-creator-compensation` does for the
|
||||||
// model licensing fee: the orchestrator charges the viewer at generation time,
|
// model licensing fee: the orchestrator charges the viewer at generation time,
|
||||||
@@ -89,23 +91,32 @@ export const BLOCK_AUTHOR_FEE_LOG_NAME = 'block-author-fee' as const;
|
|||||||
* bare string literals everywhere, so it was not referenced even inside this
|
* bare string literals everywhere, so it was not referenced even inside this
|
||||||
* file and a typo'd `'setled'` would have compiled and silently matched nothing.
|
* file and a typo'd `'setled'` would have compiled and silently matched nothing.
|
||||||
*
|
*
|
||||||
* 🔴 THERE IS NO `clawed_back` STATE AND NO `entry_type` AXIS. Round 0 retired
|
* 🔴 THERE IS NO `clawed_back` STATE AND NO `entry_type` AXIS, AND SLICE 2b DID
|
||||||
* the clawback: it had zero production callers, and its negative carry-forward
|
* NOT ADD ONE — an earlier revision of this comment predicted it would. The
|
||||||
* arm could not be reached until something had settled — two PRs away. The
|
* reversal path (`reverseBlockAuthorFee`) DELETES an unsettled row instead of
|
||||||
* reversal of a charge cannot be needed before the charge exists. Slice 2b adds
|
* marking it, for two reasons: the `status` column carries a CHECK constrained to
|
||||||
* both together, when the refund path that drives it is real.
|
* exactly `('accrued','settled')` and every migration on this database is applied
|
||||||
|
* BY HAND per environment, so a third state is an operator action, not a code
|
||||||
|
* change; and a DELETE guarded on `status = 'accrued'` is the atomic claim that
|
||||||
|
* makes the reversal idempotent under concurrent terminal observations — exactly
|
||||||
|
* one caller can delete a row, so exactly one can refund.
|
||||||
|
*
|
||||||
|
* ⚠️ THE CONSEQUENCE, STATED RATHER THAN HIDDEN: a reversed generation leaves NO
|
||||||
|
* row behind, so this table cannot answer "how many fees were reversed". That
|
||||||
|
* number lives only in the `block-author-fee` Axiom stream
|
||||||
|
* (`message: 'fee reversed'`). A SETTLED row is never reversed and never deleted
|
||||||
|
* — see `reverseBlockAuthorFee`.
|
||||||
*/
|
*/
|
||||||
export type BlockAuthorFeeAccrualStatus = 'accrued' | 'settled';
|
export type BlockAuthorFeeAccrualStatus = 'accrued' | 'settled';
|
||||||
|
|
||||||
export const STATUS_ACCRUED: BlockAuthorFeeAccrualStatus = 'accrued';
|
export const STATUS_ACCRUED: BlockAuthorFeeAccrualStatus = 'accrued';
|
||||||
/**
|
/**
|
||||||
* ⚠️ NOTHING IN THIS SLICE EVER WRITES OR READS THIS VALUE. It is exported, not
|
* ⚠️ NOTHING IN THIS MODULE EVER WRITES OR READS THIS VALUE. It is exported
|
||||||
* because this module uses it, but because the `status` column it names is a
|
* because the `status` column it names is a CHECK-constrained two-state enum, and
|
||||||
* CHECK-constrained two-state enum in the migration this slice ships, and the
|
* its writer is `settleBlockAuthorFees`. Declaring it here keeps ONE spelling of
|
||||||
* settlement job on `zach/app-blocks-author-fee-slice2b` is the writer. Declaring
|
* the literal across the accrual, settlement and reversal paths — the alternative
|
||||||
* it here keeps ONE spelling of the literal across both slices — the alternative
|
* is each re-declaring `'settled'`, and a typo there matches no row and fails
|
||||||
* is 2b re-declaring `'settled'` and a typo there matching no row and failing
|
* silently.
|
||||||
* silently. Do not read its presence as evidence that anything settles yet.
|
|
||||||
*/
|
*/
|
||||||
export const STATUS_SETTLED: BlockAuthorFeeAccrualStatus = 'settled';
|
export const STATUS_SETTLED: BlockAuthorFeeAccrualStatus = 'settled';
|
||||||
|
|
||||||
@@ -128,6 +139,96 @@ export type AccrueBlockAuthorFeeResult =
|
|||||||
| { accrued: true; id: string; feeBuzz: number }
|
| { accrued: true; id: string; feeBuzz: number }
|
||||||
| { accrued: false; reason: 'zero-fee' | 'self-dealing' | 'app-missing' | 'duplicate' | 'error' };
|
| { accrued: false; reason: 'zero-fee' | 'self-dealing' | 'app-missing' | 'duplicate' | 'error' };
|
||||||
|
|
||||||
|
/** Who this app's fee is owed to, or why nobody is. */
|
||||||
|
export type BlockAuthorFeePayee =
|
||||||
|
| { payee: true; appOwnerUserId: number }
|
||||||
|
| { payee: false; reason: 'app-missing' | 'self-dealing' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the app owner a fee is owed to, and refuse when that owner IS the
|
||||||
|
* viewer.
|
||||||
|
*
|
||||||
|
* 🔴 THIS IS THE ONE SPELLING OF THE SELF-DEALING EXCLUSION, AND EXTRACTING IT
|
||||||
|
* IS THE WHOLE POINT. Slice 2a implemented the check inside the accrual writer
|
||||||
|
* and said so in a comment addressed to slice 2b: *"today a self-dealing viewer
|
||||||
|
* would still be DEBITED by a charge path that does not consult this, and only
|
||||||
|
* the accrual would be skipped. Slice 2b must call this predicate BEFORE the
|
||||||
|
* debit, or extract it."* This is that extraction. `chargeBlockAuthorFee` calls
|
||||||
|
* it before it moves any money, and `accrueBlockAuthorFee` still calls it on the
|
||||||
|
* write side — one function, two callers, so the two can no longer disagree.
|
||||||
|
*
|
||||||
|
* ⚠️ THE ACCRUAL-SIDE CALL IS NOT REDUNDANT AND MUST NOT BE DELETED AS SUCH.
|
||||||
|
* `accrueBlockAuthorFee` is exported and its contract is "record that a debit
|
||||||
|
* happened"; a future caller that is not `chargeBlockAuthorFee` would otherwise
|
||||||
|
* write a self-dealing row. It is defence in depth against a CALLER, not against
|
||||||
|
* this function.
|
||||||
|
*
|
||||||
|
* 🔴 WHY THE OWNER IS RESOLVED HERE RATHER THAN IN THE CHARGE SERVICE. The
|
||||||
|
* ownership-gate ledger (`app-access.call-site-ledger.test.ts`) enumerates every
|
||||||
|
* production file that reads an app's owner and fails on GROWTH. Keeping the read
|
||||||
|
* in this already-enumerated file keeps that population closed; a second copy in
|
||||||
|
* a new file would be a new gate site AND a second spelling of the exclusion.
|
||||||
|
*
|
||||||
|
* Total and non-throwing on the exclusion arms; a database error propagates,
|
||||||
|
* because a charge path that cannot establish the payee must not proceed to a
|
||||||
|
* debit as though it had.
|
||||||
|
*/
|
||||||
|
export async function resolveBlockAuthorFeePayee(args: {
|
||||||
|
appId: string;
|
||||||
|
viewerUserId: number;
|
||||||
|
/** Carried onto the log lines only, so a skip is traceable to a generation. */
|
||||||
|
workflowId: string;
|
||||||
|
}): Promise<BlockAuthorFeePayee> {
|
||||||
|
const { appId, viewerUserId, workflowId } = args;
|
||||||
|
|
||||||
|
// Resolve + snapshot the app owner. 🔴 AT WRITE TIME, never at settlement: an
|
||||||
|
// app that changes hands must not retroactively move earnings already accrued
|
||||||
|
// to the previous owner (`app-ownership-transfer.service.ts` is the precedent).
|
||||||
|
const app = await dbRead.oauthClient.findUnique({
|
||||||
|
where: { id: appId },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!app?.userId) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'accrual skipped: app or owner missing',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { payee: false, reason: 'app-missing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 SELF-DEALING EXCLUSION (operator, 2026-09-18). An author running their own
|
||||||
|
// app would otherwise pay themself, which is a round trip that inflates every
|
||||||
|
// earnings number while moving no real money — and is the cheapest possible
|
||||||
|
// way to fake traction on an app.
|
||||||
|
//
|
||||||
|
// It is COUNTED, not silently dropped, so the exclusion is a number rather
|
||||||
|
// than an absence — 91% of the spend population to date is operator
|
||||||
|
// self-testing, so this arm is expected to be hot early and should not be
|
||||||
|
// mistaken for the fee failing.
|
||||||
|
if (app.userId === viewerUserId) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'info',
|
||||||
|
message: 'accrual skipped: self-dealing',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
appOwnerUserId: app.userId,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { payee: false, reason: 'self-dealing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payee: true, appOwnerUserId: app.userId };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record that a viewer has been debited an author fee, and that the app owner is
|
* Record that a viewer has been debited an author fee, and that the app owner is
|
||||||
* owed it.
|
* owed it.
|
||||||
@@ -155,59 +256,14 @@ export async function accrueBlockAuthorFee(
|
|||||||
// fee" unanswerable from the table.
|
// fee" unanswerable from the table.
|
||||||
if (computation.feeBuzz <= 0) return { accrued: false, reason: 'zero-fee' };
|
if (computation.feeBuzz <= 0) return { accrued: false, reason: 'zero-fee' };
|
||||||
|
|
||||||
// Resolve + snapshot the app owner. 🔴 AT WRITE TIME, never at settlement: an
|
// 🔴 THE SELF-DEALING EXCLUSION AND THE OWNER SNAPSHOT ARE ONE SHARED
|
||||||
// app that changes hands must not retroactively move earnings already accrued
|
// PREDICATE — see `resolveBlockAuthorFeePayee`. Slice 2a implemented it inline
|
||||||
// to the previous owner (`app-ownership-transfer.service.ts` is the precedent).
|
// here and recorded that a charge path would have to call it BEFORE the debit
|
||||||
const app = await dbRead.oauthClient.findUnique({
|
// or extract it. It is extracted, and `chargeBlockAuthorFee` calls it before it
|
||||||
where: { id: appId },
|
// takes any money; this call is the write-side belt for any OTHER caller of
|
||||||
select: { id: true, userId: true },
|
// this exported function, not a second copy of the rule.
|
||||||
});
|
const payee = await resolveBlockAuthorFeePayee({ appId, viewerUserId, workflowId });
|
||||||
if (!app?.userId) {
|
if (!payee.payee) return { accrued: false, reason: payee.reason };
|
||||||
logToAxiom(
|
|
||||||
{
|
|
||||||
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
|
||||||
type: 'warning',
|
|
||||||
message: 'accrual skipped: app or owner missing',
|
|
||||||
workflowId,
|
|
||||||
appId,
|
|
||||||
},
|
|
||||||
'civitai-prod'
|
|
||||||
).catch(() => undefined);
|
|
||||||
return { accrued: false, reason: 'app-missing' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔴 SELF-DEALING EXCLUSION (operator, 2026-09-18). An author running their own
|
|
||||||
// app would otherwise pay themself, which is a round trip that inflates every
|
|
||||||
// earnings number while moving no real money — and is the cheapest possible
|
|
||||||
// way to fake traction on an app. Excluded at ACCRUAL rather than at
|
|
||||||
// settlement, so a self-run generation never enters the ledger at all.
|
|
||||||
//
|
|
||||||
// ⚠️ IT IS IMPLEMENTED ONCE, HERE, AND NOTHING SHARES IT. An earlier revision
|
|
||||||
// claimed "the charge path reads this same predicate before taking the money".
|
|
||||||
// There is no charge path (slice 2b) and no shared predicate — slice 1 has no
|
|
||||||
// self-dealing check of any kind. So today a self-dealing viewer would still be
|
|
||||||
// DEBITED by a charge path that does not consult this, and only the accrual
|
|
||||||
// would be skipped. 🔴 Slice 2b must call this predicate BEFORE the debit, or
|
|
||||||
// extract it; do not assume the exclusion is already enforced upstream.
|
|
||||||
//
|
|
||||||
// It is COUNTED, not silently dropped, so the exclusion is a number rather
|
|
||||||
// than an absence — 91% of the spend population to date is operator
|
|
||||||
// self-testing, so this arm is expected to be hot early and should not be
|
|
||||||
// mistaken for the fee failing.
|
|
||||||
if (app.userId === viewerUserId) {
|
|
||||||
logToAxiom(
|
|
||||||
{
|
|
||||||
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
|
||||||
type: 'info',
|
|
||||||
message: 'accrual skipped: self-dealing',
|
|
||||||
workflowId,
|
|
||||||
appId,
|
|
||||||
appOwnerUserId: app.userId,
|
|
||||||
},
|
|
||||||
'civitai-prod'
|
|
||||||
).catch(() => undefined);
|
|
||||||
return { accrued: false, reason: 'self-dealing' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = newBlockAuthorFeeAccrualId();
|
const id = newBlockAuthorFeeAccrualId();
|
||||||
|
|
||||||
@@ -218,7 +274,7 @@ export async function accrueBlockAuthorFee(
|
|||||||
workflowId,
|
workflowId,
|
||||||
appId,
|
appId,
|
||||||
appBlockId,
|
appBlockId,
|
||||||
appOwnerUserId: app.userId,
|
appOwnerUserId: payee.appOwnerUserId,
|
||||||
viewerUserId,
|
viewerUserId,
|
||||||
buzzType,
|
buzzType,
|
||||||
feeBuzz: computation.feeBuzz,
|
feeBuzz: computation.feeBuzz,
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { dbWrite } from '~/server/db/client';
|
||||||
|
import { logToAxiom } from '~/server/logging/client';
|
||||||
|
import { createBuzzTransactionMany } from '~/server/services/buzz.service';
|
||||||
|
import { isAppBlocksAuthorFeeEnabled } from '~/server/services/app-blocks-flag';
|
||||||
|
import { TransactionType } from '~/shared/constants/buzz.constants';
|
||||||
|
import type { BuzzAccountType } from '~/shared/constants/buzz.constants';
|
||||||
|
import { getBuzzApiStatus } from '~/server/utils/buzz-error';
|
||||||
|
import {
|
||||||
|
accrueBlockAuthorFee,
|
||||||
|
resolveBlockAuthorFeePayee,
|
||||||
|
BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
STATUS_ACCRUED,
|
||||||
|
} from './author-fee-accrual.service';
|
||||||
|
import {
|
||||||
|
computeBlockAuthorFee,
|
||||||
|
BLOCK_AUTHOR_FEE_PRICE_IS_CAP,
|
||||||
|
BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE,
|
||||||
|
} from './author-fee';
|
||||||
|
import type { BlockAuthorFeeComputation, BlockAuthorFeeConfig } from './author-fee';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// App Blocks PER-GENERATION AUTHOR FEE — slice 2b, THE VIEWER-CHARGE PATH.
|
||||||
|
//
|
||||||
|
// Slice 1 computed the fee and threw it away. Slice 2a persisted an accrual and
|
||||||
|
// a settlement rail with NO caller. This file is the caller: it prices the fee
|
||||||
|
// before the spend guardrails run, debits the viewer after the orchestrator has
|
||||||
|
// accepted the work, writes the accrual row, and reverses both when the
|
||||||
|
// generation does not survive.
|
||||||
|
//
|
||||||
|
// ── 🔴 THE FEE IS PRICED INTO THE RESERVATION, NEVER DEBITED OUTSIDE IT ──────
|
||||||
|
// This is the one safety property the design review found missing, and it is
|
||||||
|
// what the two-function split exists for. The submit order on every path is
|
||||||
|
//
|
||||||
|
// whatIf quote → budget gate + reservations → submitWorkflow → charge
|
||||||
|
//
|
||||||
|
// If the fee were debited at the end without appearing at the start, it would
|
||||||
|
// escape EVERY guardrail those reservations implement: the token's per-call
|
||||||
|
// `buzzBudget`, the viewer's per-(user, UTC-day) platform cap, the viewer's OWN
|
||||||
|
// per-app CONSENT BUDGET, the per-app aggregate anti-Sybil cap and the
|
||||||
|
// dev-tunnel session backstop. A viewer who consented to spend N Buzz per day on
|
||||||
|
// an app would be charged N plus however much author fee the app's own
|
||||||
|
// configuration asked for — i.e. the consent budget would bound the part of the
|
||||||
|
// price the app does NOT control and leave the part it DOES control unbounded.
|
||||||
|
//
|
||||||
|
// So:
|
||||||
|
// * `quoteBlockAuthorFee` runs against the WHATIF response, and the caller adds
|
||||||
|
// its `feeBuzz` to the number every gate and every reservation is taken
|
||||||
|
// against.
|
||||||
|
// * `chargeBlockAuthorFee` runs against the REALIZED submit response, and takes
|
||||||
|
// `reservedAuthorFeeBuzz` — what the quote actually got reserved — as a HARD
|
||||||
|
// CEILING. `charged = min(reserved, realized)`.
|
||||||
|
//
|
||||||
|
// 🔴 THE CEILING IS WHAT MAKES THE PROPERTY STRUCTURAL RATHER THAN CONVENTIONAL.
|
||||||
|
// A submit path that reserves nothing passes 0 and can then charge nothing, no
|
||||||
|
// matter what the realized base says — so "did this path remember to price the
|
||||||
|
// fee in?" has a mechanical answer instead of resting on a reviewer noticing.
|
||||||
|
// Two of the four submit paths are in exactly that state on purpose; see
|
||||||
|
// `chargeBlockAuthorFee`.
|
||||||
|
//
|
||||||
|
// ── THE MONEY SHAPE ─────────────────────────────────────────────────────────
|
||||||
|
// Viewer → account 0 at submit (`TransactionType.Fee`), account 0 → author on the
|
||||||
|
// daily settlement run. The platform is a CONDUIT, not a party (D1): the author
|
||||||
|
// is credited exactly what the viewer was debited, and account 0 is a way-station
|
||||||
|
// rather than a share. A reversal is account 0 → viewer (`TransactionType.Refund`)
|
||||||
|
// and only ever for a row that has NOT settled.
|
||||||
|
//
|
||||||
|
// ── DARK ────────────────────────────────────────────────────────────────────
|
||||||
|
// `quoteBlockAuthorFee` reads `app-blocks-author-fee-enabled` FIRST and
|
||||||
|
// fail-closed. With the flag off it returns a non-charging quote before touching
|
||||||
|
// the database, so no fee is reserved, and a zero reservation then makes the
|
||||||
|
// charge structurally impossible. Merging this changes nothing until the flag is
|
||||||
|
// flipped.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Why a quote or a charge did not produce money. */
|
||||||
|
export type BlockAuthorFeeSkip =
|
||||||
|
| 'flag-disabled'
|
||||||
|
| 'price-is-cap'
|
||||||
|
| 'base-unavailable'
|
||||||
|
| 'zero-fee'
|
||||||
|
| 'self-dealing'
|
||||||
|
| 'app-missing'
|
||||||
|
| 'not-reserved'
|
||||||
|
| 'debit-failed'
|
||||||
|
| 'accrual-failed'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type BlockAuthorFeeQuote =
|
||||||
|
| {
|
||||||
|
charge: true;
|
||||||
|
/** Whole Buzz to add to the reservation. Always > 0. */
|
||||||
|
feeBuzz: number;
|
||||||
|
appOwnerUserId: number;
|
||||||
|
computation: BlockAuthorFeeComputation;
|
||||||
|
}
|
||||||
|
| { charge: false; reason: BlockAuthorFeeSkip };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price this generation's author fee BEFORE the spend guardrails run.
|
||||||
|
*
|
||||||
|
* Called with the WHATIF response's `cost.base` / `cost.variable`. The returned
|
||||||
|
* `feeBuzz` is what the caller must add to the number it gates and reserves
|
||||||
|
* against; it is also the ceiling `chargeBlockAuthorFee` will be held to.
|
||||||
|
*
|
||||||
|
* 🔴 THE FLAG IS READ FIRST, FAIL-CLOSED, AND BEFORE THE DATABASE. Every other
|
||||||
|
* arm below either costs a query (`resolveBlockAuthorFeePayee`) or emits a
|
||||||
|
* counter, and neither may happen on a generation the fee is not enabled for.
|
||||||
|
*
|
||||||
|
* 🔴 SELF-DEALING IS RESOLVED HERE, NOT ONLY AT ACCRUAL. Slice 2a's exclusion
|
||||||
|
* lived inside the accrual writer, so a charge path that did not consult it would
|
||||||
|
* debit an author for running their own app and then decline the accrual — the
|
||||||
|
* viewer pays and nobody is owed. Resolving the payee at QUOTE time means a
|
||||||
|
* self-dealing generation never even has a fee reserved, so the money is never
|
||||||
|
* inside the reservation to be taken. `chargeBlockAuthorFee` re-resolves it
|
||||||
|
* immediately before the debit as well, because the two calls are seconds apart
|
||||||
|
* and an ownership transfer in between must land on the safe side.
|
||||||
|
*
|
||||||
|
* TOTAL AND NON-THROWING. This runs on the generation submit path, before the
|
||||||
|
* orchestrator has been asked for anything. A flag-read failure, a database
|
||||||
|
* failure or anything else degrades to "no fee", never to a failed generation.
|
||||||
|
*/
|
||||||
|
export async function quoteBlockAuthorFee(args: {
|
||||||
|
/** 🔴 `WorkflowCost.base` from the WHATIF response. Never `.total`. */
|
||||||
|
baseGenerationBuzz: number | null | undefined;
|
||||||
|
/** `WorkflowCost.variable` — true when the quoted price is a CAP. */
|
||||||
|
priceIsCap: boolean | null | undefined;
|
||||||
|
generationType: unknown;
|
||||||
|
appId: string;
|
||||||
|
viewerUserId: number;
|
||||||
|
/** Log-only; a whatIf has no workflow id, so callers pass a stable label. */
|
||||||
|
workflowLabel: string;
|
||||||
|
config?: BlockAuthorFeeConfig;
|
||||||
|
}): Promise<BlockAuthorFeeQuote> {
|
||||||
|
try {
|
||||||
|
if (!(await isAppBlocksAuthorFeeEnabled())) return { charge: false, reason: 'flag-disabled' };
|
||||||
|
} catch {
|
||||||
|
// A flag read that will not resolve is not permission to charge anyone.
|
||||||
|
return { charge: false, reason: 'flag-disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔴 A CAP-PRICED GENERATION IS NOT CHARGED, AND THE CHECK PRECEDES THE BASE.
|
||||||
|
// `WorkflowCost.variable` means the quoted price is a ceiling the viewer is
|
||||||
|
// billed up front and refunded down from. A percentage of it is a fee on money
|
||||||
|
// they do not ultimately spend, and the flat leg is a toll on a job that may
|
||||||
|
// have done almost nothing. Slice 1 recorded this as the current answer and
|
||||||
|
// flagged the policy (charge on the settled cost / charge and refund pro rata /
|
||||||
|
// charge nothing) as slice 2's to decide. THE DECISION IS: charge nothing. It
|
||||||
|
// is the only one of the three that cannot take money the viewer gets back,
|
||||||
|
// and the settled-cost variant needs a terminal-time charge path this slice
|
||||||
|
// deliberately does not build.
|
||||||
|
if (args.priceIsCap === true) return { charge: false, reason: BLOCK_AUTHOR_FEE_PRICE_IS_CAP };
|
||||||
|
|
||||||
|
const base = args.baseGenerationBuzz;
|
||||||
|
if (typeof base !== 'number' || !Number.isFinite(base)) {
|
||||||
|
return { charge: false, reason: BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const computation = computeBlockAuthorFee({
|
||||||
|
baseGenerationBuzz: base,
|
||||||
|
generationType: args.generationType,
|
||||||
|
config: args.config,
|
||||||
|
});
|
||||||
|
// A zero fee is the ABSENCE of a charge, not a charge of zero — the same rule
|
||||||
|
// the accrual writer applies, for the same reason. Checked before the payee
|
||||||
|
// lookup so a 0/0 generation type (`chat-completion`) costs no query.
|
||||||
|
if (computation.feeBuzz <= 0) return { charge: false, reason: 'zero-fee' };
|
||||||
|
|
||||||
|
const payee = await resolveBlockAuthorFeePayee({
|
||||||
|
appId: args.appId,
|
||||||
|
viewerUserId: args.viewerUserId,
|
||||||
|
workflowId: args.workflowLabel,
|
||||||
|
});
|
||||||
|
if (!payee.payee) return { charge: false, reason: payee.reason };
|
||||||
|
|
||||||
|
return {
|
||||||
|
charge: true,
|
||||||
|
feeBuzz: computation.feeBuzz,
|
||||||
|
appOwnerUserId: payee.appOwnerUserId,
|
||||||
|
computation,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee quote failed — generation proceeds with no fee',
|
||||||
|
appId: args.appId,
|
||||||
|
workflowId: args.workflowLabel,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { charge: false, reason: 'error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChargeBlockAuthorFeeResult =
|
||||||
|
| { charged: true; feeBuzz: number; accrualId: string | null }
|
||||||
|
| { charged: false; reason: BlockAuthorFeeSkip };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debit the viewer the author fee for a generation the orchestrator has accepted,
|
||||||
|
* and record the accrual that says who is owed it.
|
||||||
|
*
|
||||||
|
* 🔴 CALLED AFTER A RESOLVED SUBMIT, NEVER BEFORE. A fee taken for a generation
|
||||||
|
* that then failed to submit is a charge for nothing; the orchestrator's own
|
||||||
|
* response is the first moment the work is real.
|
||||||
|
*
|
||||||
|
* 🔴 `reservedAuthorFeeBuzz` IS A CEILING, NOT A HINT — AND 0 MEANS NEVER. It is
|
||||||
|
* whatever `quoteBlockAuthorFee` returned to the caller and the caller then
|
||||||
|
* folded into its budget gate and its reservations. `charged = min(reserved,
|
||||||
|
* realized)`, so:
|
||||||
|
* * a path that priced no fee (0) can never take one, whatever the realized
|
||||||
|
* base says. TWO OF THE FOUR SUBMIT PATHS ARE DELIBERATELY IN THIS STATE:
|
||||||
|
* `submitCustomComfyWorkflow` takes no whatIf quote at all (its ceiling IS
|
||||||
|
* the app's declared `maxBuzz`) and `submitPassThroughStepWorkflow`'s quote
|
||||||
|
* helper returns a total only. Neither has a pre-submit `cost.base` to price
|
||||||
|
* a fee from, so neither reserves one, so neither charges one. They still
|
||||||
|
* call this function with 0 so the population of submit paths that route
|
||||||
|
* their fee through one place stays CLOSED and a future path that gains a
|
||||||
|
* base changes one argument rather than re-deriving the rule.
|
||||||
|
* * a realized base that moved UP between the whatIf and the submit charges the
|
||||||
|
* RESERVED amount, not the realized one. The viewer is never billed past what
|
||||||
|
* their consent budget was measured against.
|
||||||
|
*
|
||||||
|
* ⚠️ WHAT THE CLAMP DOES TO THE ROW, STATED BECAUSE IT IS NOT TIDY. The accrual
|
||||||
|
* stores `fee_buzz` = the amount CHARGED, while `flat_leg_buzz` / `pct_leg_buzz` /
|
||||||
|
* `governing_leg` describe the price that was COMPUTED. On a clamped charge those
|
||||||
|
* disagree, and `feeBuzz === max(flatLeg, pctLeg)` — an invariant that holds
|
||||||
|
* inside `computeBlockAuthorFee` — does NOT hold on the row. That is deliberate:
|
||||||
|
* D1 says the author is credited exactly what the viewer was debited, and the
|
||||||
|
* settlement rail pays `fee_buzz`, so `fee_buzz` must be the charged number. The
|
||||||
|
* clamp is logged (`feeClampedToReserve`) so the disagreement is explainable
|
||||||
|
* rather than mysterious. There is no column for it; adding one is a hand-applied
|
||||||
|
* migration, which is an operator action.
|
||||||
|
*
|
||||||
|
* 🔴 RECONCILED BY COUNT, NOT BY ABSENCE OF A THROW. `createBuzzTransactionMany`
|
||||||
|
* does NOT throw on a per-transaction failure: an `insufficientFunds` result is
|
||||||
|
* dropped from BOTH the `transactions` and `conflicts` arrays, so the money did
|
||||||
|
* not move and nothing says so. A viewer short of the fee is the ordinary case
|
||||||
|
* here, not an exotic one — they just paid for a generation. So the accrual is
|
||||||
|
* written only when `transactions.length + conflicts.length === 1`.
|
||||||
|
*
|
||||||
|
* 🔴 A LANDED DEBIT WITH A FAILED ACCRUAL IS REFUNDED, IMMEDIATELY. That pair
|
||||||
|
* means the viewer paid and nobody is owed, i.e. the platform silently keeps the
|
||||||
|
* money — the one outcome D1 forbids in both directions. The refund reuses the
|
||||||
|
* reversal key, so a later terminal reversal of the same workflow conflicts
|
||||||
|
* instead of refunding twice.
|
||||||
|
*
|
||||||
|
* TOTAL AND NON-THROWING. The submit has already succeeded and its response is
|
||||||
|
* owed to the block; a fee failure must never turn a completed generation into an
|
||||||
|
* error.
|
||||||
|
*/
|
||||||
|
export async function chargeBlockAuthorFee(args: {
|
||||||
|
/** The orchestrator workflow id — the idempotency anchor for the whole fee. */
|
||||||
|
workflowId: string;
|
||||||
|
appId: string;
|
||||||
|
appBlockId: string;
|
||||||
|
viewerUserId: number;
|
||||||
|
/** 🔴 D6 — the account the viewer's generation drained IS the fee's currency. */
|
||||||
|
buzzType: BuzzAccountType;
|
||||||
|
/** 🔴 `WorkflowCost.base` from the REALIZED submit response. Never `.total`. */
|
||||||
|
baseGenerationBuzz: number | null | undefined;
|
||||||
|
/** `WorkflowCost.variable` from the same response. */
|
||||||
|
priceIsCap: boolean | null | undefined;
|
||||||
|
generationType: string | null;
|
||||||
|
/** 🔴 The quote this path actually reserved. A hard ceiling; 0 forbids a charge. */
|
||||||
|
reservedAuthorFeeBuzz: number;
|
||||||
|
config?: BlockAuthorFeeConfig;
|
||||||
|
}): Promise<ChargeBlockAuthorFeeResult> {
|
||||||
|
const { workflowId, appId, appBlockId, viewerUserId, buzzType } = args;
|
||||||
|
|
||||||
|
// THE STRUCTURAL BOUND, AND IT IS FIRST. Nothing below — not the flag read, not
|
||||||
|
// the payee query, not the debit — may run for a path that reserved no fee.
|
||||||
|
const reserved = args.reservedAuthorFeeBuzz;
|
||||||
|
if (!(typeof reserved === 'number' && Number.isFinite(reserved) && reserved > 0)) {
|
||||||
|
return { charged: false, reason: 'not-reserved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-price against the REALIZED base and re-resolve the payee. This is where
|
||||||
|
// the self-dealing exclusion is read BEFORE the debit: `quoteBlockAuthorFee`
|
||||||
|
// returns `self-dealing` without a fee, and this function returns before it
|
||||||
|
// moves any money.
|
||||||
|
const quote = await quoteBlockAuthorFee({
|
||||||
|
baseGenerationBuzz: args.baseGenerationBuzz,
|
||||||
|
priceIsCap: args.priceIsCap,
|
||||||
|
generationType: args.generationType,
|
||||||
|
appId,
|
||||||
|
viewerUserId,
|
||||||
|
workflowLabel: workflowId,
|
||||||
|
config: args.config,
|
||||||
|
});
|
||||||
|
if (!quote.charge) return { charged: false, reason: quote.reason };
|
||||||
|
|
||||||
|
const feeBuzz = Math.min(reserved, quote.feeBuzz);
|
||||||
|
if (feeBuzz <= 0) return { charged: false, reason: 'zero-fee' };
|
||||||
|
|
||||||
|
let landed = false;
|
||||||
|
try {
|
||||||
|
const result = await createBuzzTransactionMany([
|
||||||
|
{
|
||||||
|
fromAccountId: viewerUserId,
|
||||||
|
toAccountId: 0,
|
||||||
|
fromAccountType: buzzType,
|
||||||
|
toAccountType: buzzType,
|
||||||
|
amount: feeBuzz,
|
||||||
|
description: 'App author fee',
|
||||||
|
type: TransactionType.Fee,
|
||||||
|
externalTransactionId: blockAuthorFeeChargeKey(workflowId),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// A CONFLICT is the idempotency guard — this workflow's fee already moved —
|
||||||
|
// and counts as landed, because the money is where it should be. A DROP
|
||||||
|
// (neither array) is a failure the client reports no other way.
|
||||||
|
landed = (result?.transactions?.length ?? 0) + (result?.conflicts?.length ?? 0) > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee debit threw — no fee charged',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
viewerUserId,
|
||||||
|
feeBuzz,
|
||||||
|
// `mapError` names 400/404/409 explicitly; 401, 403, 408, 429 and every
|
||||||
|
// 5xx collapse into one fixed string, so the status is the only thing
|
||||||
|
// that separates a permanent auth failure from a transient outage.
|
||||||
|
threwStatus: getBuzzApiStatus(error) ?? null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { charged: false, reason: 'debit-failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!landed) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'fee debit did not land — no fee charged',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
viewerUserId,
|
||||||
|
buzzType,
|
||||||
|
feeBuzz,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { charged: false, reason: 'debit-failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feeBuzz !== quote.feeBuzz) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'fee clamped to the reserved amount',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
feeClampedToReserve: true,
|
||||||
|
reservedBuzz: reserved,
|
||||||
|
realizedBuzz: quote.feeBuzz,
|
||||||
|
chargedBuzz: feeBuzz,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accrual = await accrueBlockAuthorFee({
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
appBlockId,
|
||||||
|
viewerUserId,
|
||||||
|
buzzType,
|
||||||
|
// 🔴 THE CHARGED AMOUNT, not the computed one — see the clamp note above.
|
||||||
|
computation: { ...quote.computation, feeBuzz },
|
||||||
|
generationType: args.generationType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accrual.accrued) return { charged: true, feeBuzz, accrualId: accrual.id };
|
||||||
|
|
||||||
|
// A DUPLICATE means a prior attempt already wrote this workflow's row, which is
|
||||||
|
// the same state a conflict on the debit describes: the fee is charged and the
|
||||||
|
// author is owed. Nothing to undo.
|
||||||
|
if (accrual.reason === 'duplicate') return { charged: true, feeBuzz, accrualId: null };
|
||||||
|
|
||||||
|
// Everything else: the viewer was debited and no row says who is owed it. Give
|
||||||
|
// the money back rather than let the platform keep it.
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee debited but accrual failed — refunding the viewer',
|
||||||
|
workflowId,
|
||||||
|
appId,
|
||||||
|
viewerUserId,
|
||||||
|
feeBuzz,
|
||||||
|
accrualReason: accrual.reason,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
await refundBlockAuthorFee({ workflowId, viewerUserId, buzzType, feeBuzz });
|
||||||
|
return { charged: false, reason: 'accrual-failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverseBlockAuthorFeeResult =
|
||||||
|
| { reversed: true; feeBuzz: number }
|
||||||
|
| { reversed: false; reason: 'no-accrual' | 'already-settled' | 'refund-failed' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give an author fee back when the generation it was charged for did not survive.
|
||||||
|
*
|
||||||
|
* 🔴 THE FEE FOLLOWS THE GENERATION. The orchestrator refunds a workflow that
|
||||||
|
* failed, expired or was cancelled — in full when it delivered nothing, prorated
|
||||||
|
* by undelivered output blobs otherwise. An author fee left standing on such a
|
||||||
|
* workflow would charge the viewer for an app's contribution to work they did not
|
||||||
|
* receive, and would pay the author out of it on the next settlement run.
|
||||||
|
*
|
||||||
|
* 🔴 THE DELETE IS THE LOCK. `pollWorkflow` reaches a terminal status on every
|
||||||
|
* subsequent poll, and `cancelAppWorkflow` can run alongside it, so this is called
|
||||||
|
* repeatedly and concurrently for one workflow. The `deleteMany` is guarded on
|
||||||
|
* `status = 'accrued'`, so exactly one caller can ever see `count === 1`, and only
|
||||||
|
* that caller issues the refund. The refund's `externalTransactionId` is a second
|
||||||
|
* layer, not the first.
|
||||||
|
*
|
||||||
|
* 🔴 A SETTLED ROW IS NOT REVERSED. The money has already been minted to the
|
||||||
|
* author; taking it back is a CLAWBACK, which slice 2a retired deliberately and
|
||||||
|
* which needs a negative-row shape the `fee_buzz > 0` CHECK forbids. The row is
|
||||||
|
* left alone and the refusal is logged with the amount, so the population is a
|
||||||
|
* number an operator can read rather than an absence. In practice the window is
|
||||||
|
* wide — settlement only ever processes COMPLETE past UTC days — so a workflow
|
||||||
|
* that reaches a terminal state on the day it ran is always still reversible.
|
||||||
|
*
|
||||||
|
* ⚠️ WHAT THIS DOES NOT COVER, SO IT IS NOT MISTAKEN FOR COVERAGE: a SUCCEEDED
|
||||||
|
* workflow that the orchestrator prorates for partially-undelivered output. Its
|
||||||
|
* terminal status is `succeeded`, nothing on this path observes the proration,
|
||||||
|
* and the fee stays at full. Charging pro rata would need the settled cost at
|
||||||
|
* terminal time, which this slice has no path to read.
|
||||||
|
*
|
||||||
|
* TOTAL AND NON-THROWING — it runs off a poll whose contract is to return a
|
||||||
|
* snapshot.
|
||||||
|
*/
|
||||||
|
export async function reverseBlockAuthorFee(args: {
|
||||||
|
workflowId: string;
|
||||||
|
/** The terminal status that drove the reversal. Log-only. */
|
||||||
|
terminalStatus: string;
|
||||||
|
}): Promise<ReverseBlockAuthorFeeResult> {
|
||||||
|
const { workflowId, terminalStatus } = args;
|
||||||
|
try {
|
||||||
|
const row = await dbWrite.blockAuthorFeeAccrual.findUnique({
|
||||||
|
where: { workflowId },
|
||||||
|
select: { id: true, status: true, viewerUserId: true, buzzType: true, feeBuzz: true },
|
||||||
|
});
|
||||||
|
if (!row) return { reversed: false, reason: 'no-accrual' };
|
||||||
|
if (row.status !== STATUS_ACCRUED) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'warning',
|
||||||
|
message: 'fee already settled — not reversed',
|
||||||
|
workflowId,
|
||||||
|
terminalStatus,
|
||||||
|
feeBuzz: row.feeBuzz,
|
||||||
|
viewerUserId: row.viewerUserId,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { reversed: false, reason: 'already-settled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The atomic claim. Re-asserting `status` here rather than trusting the read
|
||||||
|
// above is what makes it a claim instead of a check — the row can settle
|
||||||
|
// between the two statements.
|
||||||
|
const { count } = await dbWrite.blockAuthorFeeAccrual.deleteMany({
|
||||||
|
where: { workflowId, status: STATUS_ACCRUED },
|
||||||
|
});
|
||||||
|
if (count < 1) return { reversed: false, reason: 'no-accrual' };
|
||||||
|
|
||||||
|
const refunded = await refundBlockAuthorFee({
|
||||||
|
workflowId,
|
||||||
|
viewerUserId: row.viewerUserId,
|
||||||
|
buzzType: row.buzzType as BuzzAccountType,
|
||||||
|
feeBuzz: row.feeBuzz,
|
||||||
|
});
|
||||||
|
if (!refunded) return { reversed: false, reason: 'refund-failed' };
|
||||||
|
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'info',
|
||||||
|
message: 'fee reversed',
|
||||||
|
workflowId,
|
||||||
|
terminalStatus,
|
||||||
|
viewerUserId: row.viewerUserId,
|
||||||
|
buzzType: row.buzzType,
|
||||||
|
feeBuzz: row.feeBuzz,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { reversed: true, feeBuzz: row.feeBuzz };
|
||||||
|
} catch (error) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee reversal failed',
|
||||||
|
workflowId,
|
||||||
|
terminalStatus,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return { reversed: false, reason: 'refund-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return one workflow's fee to the viewer. Reconciled BY COUNT for the same
|
||||||
|
* reason the debit is — a dropped refund is invisible in the return value.
|
||||||
|
*
|
||||||
|
* ⚠️ THE ROW IS ALREADY GONE WHEN THIS RUNS, on the reversal path. A refund that
|
||||||
|
* does not land therefore leaves the viewer charged with no row to retry from,
|
||||||
|
* which is why it is logged at `error` with the amount and the account: that log
|
||||||
|
* line is the only recovery handle. The alternative ordering — refund, then
|
||||||
|
* delete — trades it for the worse failure, a refunded viewer whose row settles
|
||||||
|
* and pays the author out of platform funds.
|
||||||
|
*/
|
||||||
|
async function refundBlockAuthorFee(args: {
|
||||||
|
workflowId: string;
|
||||||
|
viewerUserId: number;
|
||||||
|
buzzType: BuzzAccountType;
|
||||||
|
feeBuzz: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { workflowId, viewerUserId, buzzType, feeBuzz } = args;
|
||||||
|
try {
|
||||||
|
const result = await createBuzzTransactionMany([
|
||||||
|
{
|
||||||
|
fromAccountId: 0,
|
||||||
|
toAccountId: viewerUserId,
|
||||||
|
fromAccountType: buzzType,
|
||||||
|
toAccountType: buzzType,
|
||||||
|
amount: feeBuzz,
|
||||||
|
description: 'App author fee refund',
|
||||||
|
type: TransactionType.Refund,
|
||||||
|
externalTransactionId: blockAuthorFeeReversalKey(workflowId),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const landed = (result?.transactions?.length ?? 0) + (result?.conflicts?.length ?? 0) > 0;
|
||||||
|
if (landed) return true;
|
||||||
|
} catch (error) {
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee refund threw — viewer is still charged',
|
||||||
|
workflowId,
|
||||||
|
viewerUserId,
|
||||||
|
buzzType,
|
||||||
|
feeBuzz,
|
||||||
|
threwStatus: getBuzzApiStatus(error) ?? null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logToAxiom(
|
||||||
|
{
|
||||||
|
name: BLOCK_AUTHOR_FEE_LOG_NAME,
|
||||||
|
type: 'error',
|
||||||
|
message: 'fee refund did not land — viewer is still charged',
|
||||||
|
workflowId,
|
||||||
|
viewerUserId,
|
||||||
|
buzzType,
|
||||||
|
feeBuzz,
|
||||||
|
},
|
||||||
|
'civitai-prod'
|
||||||
|
).catch(() => undefined);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewer-debit idempotency key. ONE spelling, workflow-derived, so
|
||||||
|
* `submitWorkflow`'s own internal retry and a client resubmit under the same
|
||||||
|
* orchestrator `externalId` collapse onto one charge.
|
||||||
|
*
|
||||||
|
* 🔴 THE PREFIX IS DISTINCT FROM THE SETTLEMENT KEY'S ON PURPOSE. Settlement
|
||||||
|
* builds `block-author-fee-<day>-<owner>-<currency>`; a bare `block-author-fee-`
|
||||||
|
* prefix here would put a workflow id and a settlement tuple in one namespace,
|
||||||
|
* where a collision moves money to the wrong side of the ledger.
|
||||||
|
*/
|
||||||
|
export function blockAuthorFeeChargeKey(workflowId: string): string {
|
||||||
|
return `block-author-fee-charge-${workflowId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reversal key. Shared by the accrual-failure refund and the terminal reversal. */
|
||||||
|
export function blockAuthorFeeReversalKey(workflowId: string): string {
|
||||||
|
return `block-author-fee-reversal-${workflowId}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user