Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/billing-idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ CREATE TABLE usage_events (
CREATE UNIQUE INDEX idx_usage_events_request_id ON usage_events(request_id);
```

## Result Flags

The result object returned by `deduct` (and reported per entry by `deductBulk`) carries boolean flags that callers must interpret together. Reporting a pending or failed row as a success is the most common bug in consumers of this service.

| Flag | Meaning when `true` | Meaning when `false` |
| ---- | -------------- | --------------- |
| `success` | The deduction completed and the Stellar transaction hash is persisted. The row is final. | The deduction did not complete. Inspect `error` (and `simulationDetails` when present). |
| `alreadyProcessed` | The `requestId` already existed and the prior result was returned. No new Soroban call was made. | This invocation is the one that created the usage event. |
| `deductionApplied` | A transfer was applied for this request (newly or previously). | No transfer was applied; the row is pending reconciliation. |
| `reconciliationRequired` | The row exists but has no `stellar_tx_hash`, so the outcome must be reconciled before it is treated as final. | No reconciliation is needed. |

A successful result always has `success === true`. A failed result always has `success === false` and a non-empty `error`. `alreadyProcessed` and `reconciliationRequired` are meaningful in both cases: a pending row is `success === false` with `reconciliationRequired === true`, not a success.

## Usage Examples

### Basic Usage
Expand Down Expand Up @@ -598,4 +611,4 @@ UPDATE usage_events SET status = 'applied' WHERE stellar_tx_hash IS NOT NULL;

- [Idempotency Keys - Stripe Documentation](https://stripe.com/docs/api/idempotent_requests)
- [PostgreSQL Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS)
- (Database Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)
- [Database Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)
88 changes: 88 additions & 0 deletions src/services/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ export interface BillingServiceOptions {
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Convert a human-readable USDC amount into 7-decimal Soroban contract units.
*
* USDC on Stellar uses 7 decimal places, so `1` USDC becomes `10_000_000n`.
* The input is trimmed and must be a positive decimal with at most 7 fractional
* digits; extra digits are neither rounded nor silently accepted.
*
* @param amountUsdc - Decimal USDC amount, e.g. `"0.01"`.
* @returns The amount scaled by `10^7` as a `bigint`.
* @throws {Error} When the value is not a positive decimal with at most 7
* fractional digits, or when it scales to zero.
*/
export function parseUsdcToContractUnits(amountUsdc: string): bigint {
const trimmed = amountUsdc.trim();
if (!/^\d+(\.\d{1,7})?$/.test(trimmed)) {
Expand Down Expand Up @@ -213,10 +225,32 @@ function classifyTransientError(error: unknown): boolean {
return transientPatterns.some((pattern) => pattern.test(message));
}

/**
* Classify a Soroban/network error as transient (safe to retry) or not.
*
* Classification is by error type and category rather than substring matching,
* so messages that merely contain numbers such as `503` or `429` are not
* misread as retryable. Transient errors are `SorobanRpcError`s with a
* `TIMEOUT`/`NETWORK_ERROR` category, Node system errors carrying a known
* transient errno, and — as a last-resort fallback — plain errors whose message
* matches word-boundary network/rate-limit patterns.
*
* @param error - Any thrown value.
* @returns `true` when the error looks retryable, otherwise `false`.
*/
export function isTransientSorobanError(error: unknown): boolean {
return classifyTransientError(error);
}

/**
* Format 7-decimal Soroban contract units back into a USDC string.
*
* The inverse of {@link parseUsdcToContractUnits}: trailing zeros are trimmed
* and the decimal point is dropped for whole-number amounts.
*
* @param amount - Contract units (`bigint`, scaled by `10^7`).
* @returns A decimal USDC string such as `"0.01"` or `"3"`.
*/
export function formatContractUnitsToUsdc(amount: bigint): string {
const whole = amount / USDC_7_DECIMAL_FACTOR;
const fraction = amount % USDC_7_DECIMAL_FACTOR;
Expand Down Expand Up @@ -469,10 +503,26 @@ async function runPhase1Bulk(
// BillingService
// ---------------------------------------------------------------------------

/**
* Records usage events and deducts prepaid USDC from a developer's Soroban
* balance.
*
* Deduction is idempotent on `requestId`: the first call inserts a usage event
* and performs the Soroban transfer, while later calls with the same id return
* the persisted row without a second transfer. Concurrent deductions for the
* same user are serialised (see {@link billingConcurrencySemaphore}) so the
* balance pre-check and the transfer cannot interleave into an overdraft.
*/
export class BillingService {
private readonly retryDelaysMs: number[];
private readonly random: RandomSource;

/**
* @param pool - Postgres pool used to persist usage events.
* @param sorobanClient - Client that reads balances and submits transfers.
* @param options - Retry schedule and jitter source; see
* {@link BillingServiceOptions}.
*/
constructor(
private readonly pool: Pool,
private readonly sorobanClient: SorobanClient,
Expand All @@ -482,6 +532,20 @@ export class BillingService {
this.random = options.random ?? Math.random;
}

/**
* Deduct `request.amountUsdc` from `request.userId` and record a usage event.
*
* Runs under the per-user concurrency slot. If the `requestId` was already
* processed, the persisted result is returned with `alreadyProcessed: true`
* and no new Soroban call is made. A pre-flight balance check fails fast
* before any row is written.
*
* @param request - Deduction details; `requestId` is the idempotency key.
* @returns The outcome. Read the flags together: `success` is `true` only when
* the transfer completed and `stellarTxHash` is persisted; on failure `error`
* (and possibly `simulationDetails`) explains why, and
* `reconciliationRequired` marks a pending row that still needs reconciling.
*/
async deduct(request: BillingDeductRequest): Promise<BillingDeductResult> {
// Serialise deductions per user so the Soroban balance pre-check and the
// deduction cannot interleave across concurrent requests for the same user.
Expand All @@ -490,6 +554,21 @@ export class BillingService {
);
}

/**
* Deduct several usage entries for a single user in one batch.
*
* Every entry must target the same `userId`. The batch is idempotent on the
* set of `requestId`s (or the caller-supplied `idempotencyKey`) and submits a
* single Soroban transfer for the summed amount.
*
* @param requests - Non-empty list of deductions, all for one user.
* @param idempotencyKey - Optional key; derived from the sorted request ids
* when omitted.
* @returns Aggregate flags (`success`, `deductedCount`,
* `totalDeductedAmountUsdc`) plus a per-entry `results` array whose items
* carry the same `alreadyProcessed` / `deductionApplied` /
* `reconciliationRequired` semantics as {@link BillingService.deduct}.
*/
async deductBulk(
requests: BillingDeductRequest[],
idempotencyKey?: string,
Expand Down Expand Up @@ -919,6 +998,15 @@ export class BillingService {
};
}

/**
* Look up a previously recorded usage event by its request id.
*
* @param requestId - The idempotency key used when the deduction was made.
* @returns The persisted result, or `null` when no event exists. `success`
* reflects whether a Stellar transaction hash is present, so a pending row is
* reported with `success: false` and `reconciliationRequired: true` rather
* than as a success.
*/
async getByRequestId(requestId: string): Promise<BillingDeductResult | null> {
const result = await this.pool.query<{
id: string;
Expand Down
Loading