/** * @pwngh/economy-lab * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ import type { Amount } from './money.js'; import type { Operation } from './contract.js'; import type { Clock, Ids, InboxMessage, Meter, CallOptions, Store } from './ports.js'; /** * Fields every verified provider callback carries, whatever its kind. `provider` comes from the * URL route, not the body, so it cannot be spoofed. `eventId` is the provider's globally-unique id * for this delivery and the basis for applying at most once: the server claims it in its replay * store, and the mapped operation's dedup key is derived from it. */ type WebhookBase = { provider: string; eventId: string; }; /** * A verified inbound purchase event from a billing provider (Steam / Meta / Apple / Google, or * any payment processor): the user paid real money and their spendable balance should be * credited. `kind` is optional and defaults to `'purchase'` so a bare purchase object is a valid * `WebhookEvent`. */ export type PurchaseEvent = WebhookBase & { kind?: 'purchase'; /** The end user whose spendable balance the purchase credits. */ userId: string; /** How much to grant, in the platform's CREDIT currency. */ amount: Amount; /** Where the money came from, recorded on the topUp (e.g. 'card', 'steam'). Free-form. */ source: string; /** The product purchased, when the event is a product buy rather than a bare credit pack. */ sku?: string; }; /** * A verified payout-settled callback: the payout rail reports it disbursed the USD for one of our * submitted payouts. It drives the SUBMITTED -> SETTLED saga step via `settlePayout`; * `providerRef`/`providerAmount` are audit-trail only — the posted figures are the rate-derived * ones `settlePayout` computes. */ export type PayoutSettledEvent = WebhookBase & { kind: 'payoutSettled'; /** The payout saga (pay_) this settlement clears. */ sagaId: string; /** The rail's own reference for this disbursement, recorded for the audit trail. */ providerRef: string; /** * The USD the provider reported settling, when the callback carries one. Recorded for * reconciliation only. */ providerAmount?: Amount; }; /** * A verified payout-failed callback: the payout rail reports it will not disburse one of our * submitted payouts. It drives `reversePayout` with `providerReported` set, so the seller's * reserve returns as soon as the rail gives up rather than after the `maxPayoutAgeMs` timeout. */ export type PayoutFailedEvent = WebhookBase & { kind: 'payoutFailed'; /** The payout saga (id of the form pay_) whose disbursement the provider gave up on. */ sagaId: string; /** * The seller the payout belongs to. The submit pipeline locks accounts by this id, and * `reversePayout` refuses the operation if it does not match the saga's own user. */ userId: string; /** The rail's own reference for the failed disbursement, recorded for the audit trail. */ providerRef?: string; /** The rail's failure reason (e.g. its status code or a human string), recorded on the reversal. */ reason?: string; }; /** * A verified dispute / chargeback callback: the user's bank reversed a charge, so the credits * that purchase issued must be reclaimed via `clawback`. Carries the disputed order when the * provider names one, so the clawback and a refund of the same order stay mutually exclusive. */ export type DisputeEvent = WebhookBase & { kind: 'dispute'; /** The user whose credits the chargeback reclaims. */ userId: string; /** How much to reclaim, in the platform's CREDIT currency. */ amount: Amount; /** The order the chargeback disputes, when the provider ties the dispute to one. */ orderId?: string; /** Free-form reason recorded on the clawback (e.g. the network's chargeback reason code). */ reason?: string; }; /** * A verified inbound provider callback, tagged by `kind`. The webhook edge decodes the raw body * into one of these and {@link handleWebhook} dispatches it by kind to the operation it applies. A * purchase may omit its `kind` (see {@link PurchaseEvent}). */ export type WebhookEvent = PurchaseEvent | PayoutSettledEvent | PayoutFailedEvent | DisputeEvent; /** * Dedup key for a webhook-driven operation, derived from the provider `eventId` and namespaced * with `whk:` so it can't collide with a caller-supplied key. Second guard behind the replay * store: a claim can still be invisible to a concurrent redelivery, and this key catches that * duplicate at the ledger. */ export declare function webhookIdempotencyKey(eventId: string): string; /** * Builds the `topUp` that credits the buyer from a verified {@link PurchaseEvent}. * `eventId` / `sku` / `provider` ride along as provenance so the ledger entry can be traced back * to the callback. */ export declare function toTopUp(event: PurchaseEvent): Operation; /** * Builds the `settlePayout` that clears a submitted payout from a verified * {@link PayoutSettledEvent}. The actor is `system`, which `settlePayout`'s privileged-only gate * (RESTRICTED_TO_PRIVILEGED) requires. */ export declare function toSettlePayout(event: PayoutSettledEvent): Operation; /** * Builds the `reversePayout` that promptly returns a failed payout's reserve from a verified * {@link PayoutFailedEvent}. `providerReported` is set here and only here: it waives the * still-live SUBMITTED refusal on the rail's own report, and the saga-state compare-and-set still * stands down if a settle callback won the race. The reason defaults to the stable * `payout.provider_failed` marker when the rail gave none. The actor is `system`, which the * privileged-only gate requires. */ export declare function toReversePayout(event: PayoutFailedEvent): Operation; /** * Builds the `clawback` that reclaims disputed credits from a verified {@link DisputeEvent}. * `orderId` threads through so the clawback shares the `reversed:${orderId}` key with a refund of * the same order. The actor is `system`, which `clawback` already allows (system-or-operator). */ export declare function toClawback(event: DisputeEvent): Operation; /** * Dispatches a verified {@link WebhookEvent} to the {@link Operation} it applies — the single * place provider-event kind maps to economy operation. Every branch derives the same * `eventId`-based dedup key, so whichever kind arrives is applied at most once. */ export declare function toOperation(event: WebhookEvent): Operation; /** * The result of accepting a verified webhook. * - `accepted`: a fresh provider event, enqueued for the next sweep. * - `duplicate`: a redelivery of an already-seen `eventId`; the existing row stood. * * `entry` is the stored row, so the caller can surface its id without a second read. */ export type WebhookReceipt = { status: 'accepted' | 'duplicate'; entry: InboxMessage; }; /** * Maps a verified callback to the operation it applies (via {@link toOperation}), persists that * to the inbox in one transaction, and returns. It does NOT post to the ledger inline; the apply * worker (`drainInbox`) submits the stored Operation later, so invariants and idempotency apply * there. * * @example * const receipt = await handleWebhook(ports.store, ports, { * provider: 'steam', * eventId: 'evt_8123', * userId: 'u_42', * amount: toAmount('CREDIT', 12_000n), // a $100 credit pack * source: 'steam', * }); * // receipt.status is 'accepted' now, 'duplicate' on any redelivery of evt_8123 * * @see {@link https://economy-lab-docs.pages.dev/economy/reference/http-service/ HTTP service} for * the verification gate the edge runs first. * @see {@link https://economy-lab-docs.pages.dev/economy/ports/processor/ Processor} for how * verified callbacks flow through the inbox. */ export declare function handleWebhook(store: Store, ctx: { ids: Ids; clock: Clock; meter?: Meter; }, event: WebhookEvent, options?: CallOptions): Promise; /** * The purchase case of {@link handleWebhook}, kept as a named entry point for callers that only * deal in purchases. */ export declare function handlePurchaseWebhook(store: Store, ctx: { ids: Ids; clock: Clock; meter?: Meter; }, event: PurchaseEvent, options?: CallOptions): Promise; /** * Decodes an already-parsed purchase webhook body into a typed {@link PurchaseEvent}. The money * field uses the same decimal-string codec as the rest of the API, so the amount never passes * through a JSON number; a wrong-shape body or bad amount throws, letting the server reply 400 * before anything reaches the ledger. Purchase is the only kind with a body decoder — the other * kinds map straight from a {@link WebhookEvent} via {@link toOperation}. */ export declare function decodeWebhookEvent(provider: string, body: unknown): PurchaseEvent; export {};