@coinfra/payments
v0.1.0
Published
The money wheel: provider-neutral payment orders, subscriptions, webhook reliability, wallets, ledger holds, metering, and USDT escrow adapters.
Downloads
85
Readme
@coinfra/payments
The money wheel. Provider-neutral payment orders, subscriptions, reliable webhooks, wallets, an append-only ledger, metering holds, payouts, and USDT escrow — embedded in your application.
Coinfra does not replace Stripe, PayPal, Alipay, WeChat Pay, PostgreSQL, or a blockchain node. It keeps their native SDKs and data, while providing one lifecycle and one set of financial invariants around them.
Status
0.1.0-alpha.5 is used by two real consumers:
- a SaaS product using Stripe subscription Checkout, Customer Portal, webhooks, trials and generation entitlements;
- an AI marketplace using one-time wallet top-ups, Payment Orders, ledger entries, usage holds, authoritative usage settlement, debt, seller settlement and TRC20/ERC20 USDT deposits.
Alpha means the API may still change. It does not mean mock money: the state machines, integer arithmetic, webhook signature handling and PostgreSQL consumer tests are real.
Install
pnpm add @coinfra/paymentsInstall only the native provider SDKs you use:
pnpm add stripe # Stripe
pnpm add @paypal/paypal-server-sdk # PayPal
pnpm add alipay-sdk # AlipayModules
| Import | Purpose |
|---|---|
| @coinfra/payments | Core money, orders, webhook orchestration, entitlements, ledger contracts, payouts |
| @coinfra/payments/stripe | Stripe Checkout, subscriptions, Portal, refunds, signed webhooks and reconciliation |
| @coinfra/payments/paypal | PayPal Orders/Subscriptions and official REST webhook verification |
| @coinfra/payments/alipay | Alipay page pay, signed notifications, query and refunds |
| @coinfra/payments/wechat-pay | WeChat Pay v3 client contract and normalized Native/JSAPI/H5/App lifecycle |
| @coinfra/payments/escrow/usdt | TRC20/ERC20 USDT deposit and withdrawal state machine |
| @coinfra/payments/testing | Fake provider and executable in-memory reference repositories |
Money
No floating point is used for stored or calculated money.
import { money, formatMajorUnits, parseMajorUnits } from '@coinfra/payments';
money(1900, 'USD'); // $19.00
formatMajorUnits(money(1234, 'KWD')); // "1.234"
parseMajorUnits('12.34', 'USD'); // { amountMinor: 1234n, currency: 'USD' }Zero-, two-, three- and four-decimal ISO currencies are supported. Excess precision is rejected rather than rounded silently.
Stripe subscriptions (SaaS)
import Stripe from 'stripe';
import { stripePayments } from '@coinfra/payments/stripe';
const stripe = stripePayments({
client: new Stripe(process.env.STRIPE_SECRET_KEY!),
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
environment: 'test',
});
const checkout = await stripe.createSubscriptionCheckout!({
ownerId: merchant.id,
customer: { id: stripeCustomerId, email: owner.email },
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
quantity: 1,
allowPromotionCodes: true,
successUrl: 'https://app.example.com/billing?success=1',
cancelUrl: 'https://app.example.com/billing?canceled=1',
metadata: { ownerId: merchant.id, plan: 'starter' },
});Use parseWebhook() with the raw request body. A browser success redirect must never
activate access or credit money.
const event = await stripe.parseWebhook({ body: rawBody, headers: request.headers });
if (event.kind === 'subscription_changed') {
await repository.saveSubscription(event.subscription);
}The normalized snapshot retains the native provider status (active, past_due,
incomplete, unpaid, etc.), cycle timestamps and raw provider object.
One-time payments and wallet top-ups
createPayments() owns the provider-neutral Payment Order, idempotency and webhook inbox.
The application owns its database implementation and what happens after confirmation.
const payments = createPayments({ provider, repository });
const order = await payments.createOneTimeCheckout({
ownerId: buyer.id,
amount: money(5000, 'CNY'),
description: 'Wallet top-up',
successUrl,
cancelUrl,
idempotencyKey,
});
await payments.processWebhook({
body: rawBody,
headers,
handlers: {
async onPaymentConfirmed({ order }, tx) {
// This callback and the order/event transition share the application
// repository transaction. Credit the wallet and append its ledger entry here.
await wallet.credit(order.ownerId, order.amount, order.id, tx);
},
},
});The same provider event can be delivered repeatedly without applying the business credit more than once. Amount and currency mismatches reject the entire transaction.
Wallet, ledger and metering
LedgerRepository defines the required semantics; the testing package contains the
reference implementation and reusable behavior:
await ledger.credit({ ... });
await ledger.debit({ ... });
await ledger.reserve({ referenceType: 'runtime_request', referenceId, amount, ... });
await ledger.capture({ referenceType: 'runtime_request', referenceId, actualAmount, allowDebt: true });
await ledger.release({ referenceType: 'runtime_request', referenceId });
await ledger.releaseExpired();Invariants:
- balances never go negative;
- every mutation is idempotent by reference;
- available and frozen buckets move atomically;
- capture below a hold releases the difference;
- capture above a hold takes the remaining available balance and can create debt;
- ledger entries are append-only; a wallet balance is a read-optimized snapshot.
For multiple usage dimensions, round once across all items:
calculateMeteredCharge({
items: [
{ units: inputTokens, pricePerUnit: money(inputRate, 'CNY') },
{ units: outputTokens, pricePerUnit: money(outputRate, 'CNY') },
],
unitScale: 1_000_000,
});Entitlements
const access = evaluateEntitlement({ active: subscriptionActive, used: 47, limit: 200 });
// { allowed: true, remaining: 153n, reason: 'allowed', ... }Products decide when trials begin, how usage is counted, and which entitlement a plan provides. Coinfra only evaluates the supplied state.
Provider support
| Provider / rail | One-time | Subscription | Signed webhook | Refund | Payout | |---|---:|---:|---:|---:|---:| | Stripe | ✅ | ✅ | ✅ | ✅ | extension point | | PayPal | ✅ | ✅ | ✅ via official REST verification | client extension | extension point | | Alipay | ✅ page pay | — | ✅ official SDK | ✅ | extension point | | WeChat Pay v3 | ✅ Native/JSAPI/H5/App | — | ✅ delegated to injected verified v3 client | ✅ client contract | extension point | | USDT TRC20/ERC20 | deposit | — | chain confirmations | withdrawal broadcaster | manual/adapter-driven | | Fake provider | ✅ | ✅ | deterministic | testing | testing |
Stripe, PayPal and Alipay use their maintained native SDKs/protocols. For WeChat Pay, Coinfra intentionally accepts an injected verified v3 client instead of locking consumers to a weakly maintained unofficial Node package.
PayPal official adapter
import { Client, Environment } from '@paypal/paypal-server-sdk';
import { paypalOfficialClient, paypalPayments } from '@coinfra/payments/paypal';
const native = new Client({
environment: Environment.Sandbox,
clientCredentialsAuthCredentials: {
oAuthClientId: process.env.PAYPAL_CLIENT_ID!,
oAuthClientSecret: process.env.PAYPAL_CLIENT_SECRET!,
},
});
const provider = paypalPayments({
client: paypalOfficialClient({
client: native,
clientId: process.env.PAYPAL_CLIENT_ID!,
clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
webhookId: process.env.PAYPAL_WEBHOOK_ID!,
environment: 'test',
}),
});The SDK handles Orders and Subscriptions; Coinfra calls PayPal's official
verify-webhook-signature REST endpoint because that endpoint is not currently included in
the generated TypeScript Server SDK.
USDT escrow
createUsdtEscrow() provides the provider-neutral state machine for:
- one derived address per deposit;
- TRC20/ERC20 network and token-contract identity;
- confirmation thresholds and transfer-event deduplication;
- underpayment tolerance;
- fiat-rate snapshot at deposit creation;
- frozen-wallet credit;
- withdrawal request, cooldown and address validation;
- adapter-driven broadcast and idempotent completion.
Applications inject:
UsdtChainAdapter(derive address, poll transfer logs, validate address);UsdtEscrowRepository;LedgerRepository;- optionally
UsdtWithdrawalBroadcaster.
Private keys and HD mnemonics must live behind a secret-store/KMS/Vault implementation, never in the application database. The package does not claim that running a custodial USDT service is legally permitted in your jurisdiction; KYC/AML, custody, accounting and operational security remain deployment responsibilities.
Payouts and settlements
const settlement = calculateSettlement({
gross: money(10_000, 'CNY'),
platformFeeBps: 1500,
fixedFee: money(100, 'CNY'),
});
const payouts = createPayouts({
repository,
secretStore,
provider, // optional; omit for manual-review payouts
requireApproval: true,
});Raw bank/Alipay/crypto destinations are stored through PayoutSecretStore; public payout
records contain only a masked destination and secret reference. A successful payout deletes
the stored destination.
Testing
import {
FakePaymentProvider,
MemoryLedgerRepository,
MemoryPaymentRepository,
MemoryUsdtEscrowRepository,
} from '@coinfra/payments/testing';The package test suite covers money precision, idempotency conflicts, concurrent replay, webhook duplication, amount tampering, transaction rollback, out-of-order events, subscription state, holds, debt, property-based balance invariants, Stripe signatures, PayPal verification, payouts, TRC20/ERC20 escrow semantics and tarball ESM/CJS imports.
Security rules
- Never collect or store card data; use provider-hosted Checkout or native provider SDKs.
- Never credit from a success redirect. Credit only after a signed webhook or authoritative provider reconciliation.
- Preserve raw provider status and data; normalization must not erase information.
- Store money as integer minor units (
bigint). - Make all external mutations idempotent.
- Keep provider network calls outside database transactions; commit the resulting state and business credit atomically afterward.
- Treat webhook delivery as duplicated, delayed and out of order by default.
- Use raw request bytes for signature verification.
- Keep payout destinations, wallet mnemonics and private keys in Vault/KMS/secret storage.
Deliberate alpha limits
- No built-in tax or invoice engine.
- No automatic Stripe Connect seller onboarding yet.
- No claim that USDT custody/withdrawal is production-compliant without deployment-specific KYC/AML and key-management controls.
- WeChat Pay requires a correctly implemented/injected v3 client for signing and callback decryption.
- Database adapters are application-owned in alpha; in-memory reference implementations are shipped, and the two initial consumers provide Prisma/SQLite and PostgreSQL integration.
License
MIT © corelli
