@krizaka/payments
Les paiements derrière un port : paiement avec rabais, webhooks vérifiés et appliqués une fois, remboursements et expiration, montants entiers.
Installer
npm install @krizaka/paymentsAussi publié en sous-chemins : @krizaka/payments/settings, @krizaka/payments/testkit.
Le README ci-dessous est celui du paquet sur npm, en anglais.
Payments for every Krizaka product, the processor behind a port. Moved here from krizaka-ui (same npm name, same version line: 0.2.2 became 0.3.0). The domain talks to PaymentsPort and to vendor-free PaymentEvents; Stripe is one adapter (processorAdapter, over fetch, no SDK). Another processor is another adapter.
Money is integer minor units with a currency, never a float.
money(1149, "CAD")is{ amountMinor: 1149, currency: "cad" }. Prices are data (the@krizaka/settingsprice catalogue, ADR-009; the processor holds the price objects built from it). Taxes (GST/QST) are the processor's (Stripe Tax,automatic_tax, computed on the discounted amount) and never written here. No live key is read by this package: the keys are arguments, the setup command refuses live mode unless you say you are the owner, and every test uses fakes.
import { announcePayment, applyOnceInTransaction, dbDiscountStore, processorAdapter } from "@krizaka/payments";
import { transaction } from "@krizaka/db";
const payments = processorAdapter({
secretKey,
webhookSecret, // from the environment: PAYMENTS_STRIPE_SECRET_KEY, PAYMENTS_STRIPE_WEBHOOK_SECRET
plans: { pro: { lookupKey: "yomizaka.pro", mode: "subscription" } },
clock, // needed for expiresAt
discounts: dbDiscountStore(query), // needed for a discount
webhookToleranceSeconds, // the admin's payments.webhookToleranceSeconds
});
// checkout, with a discount from the coupons module, a retry-safe key and a deadline
const { url, checkoutRef, expiresAt } = await payments.checkout({
kind: "pro",
customerId,
accountId,
successUrl,
cancelUrl,
locale: "fr",
discount: terms, // DiscountTerms: one Stripe coupon per (couponId, termsVersion)
idempotencyKey: redemptionId, // a retry returns the same session
metadata: { redemption_id: redemptionId },
expiresAt, // 31 minutes to 24 hours from now, else PAYMENTS_BAD_EXPIRY
});
// the webhook route: verify, translate, then record the event and its effect in ONE transaction
const event = payments.parseWebhook(await req.text(), req.headers, Math.floor(clock.now().valueOf() / 1000)); // throws PaymentsError on a bad signature
await applyOnceInTransaction(
(work) => transaction(connect, work),
event,
{
payment_paid: async (e, tx) => {
await coupons.redeem(tx, {
redemptionId: e.metadata.redemption_id,
checkoutRef: e.checkoutRef,
paymentRef: e.paymentRef,
amountPaid: e.amountTotal,
amountDiscount: e.amountDiscount,
currency: e.currency,
});
await announcePayment(tx, outbox, e); // evt.payment.paid.v1 on the « payments » topic, same transaction
},
checkout_expired: async (e, tx) => void (await coupons.release(tx, e.checkoutRef)),
payment_refunded: async (e, tx) =>
void (await coupons.refund(tx, { paymentRef: e.paymentRef, amountRefunded: e.amountRefunded })),
},
{ clock },
);What it gives
- The port.
createCustomer(idempotent per account),checkout,portal,price,charge,refund,parseWebhook. - A one-off charge per item.
charge({ key, customerId, amountMinor, currency, description, taxCode })makes one invoice with one line taxed by Stripe Tax (amount before tax), finalises and pays it with the saved payment method; every call is keyed bykey. A declined payment voids the invoice and throwsPAYMENTS_DECLINED;refund(ref, { key, amountMinor })takes the invoice or payment reference. - Discount terms.
CheckoutRequest.discountisDiscountTerms(couponId,termsVersion,percentOffBpbasis points oramountOffMinor+currency,durationonce / repeating / forever). The Stripe adapter makes one Stripe coupon per(couponId, termsVersion)(idempotency keycoupon-<id>-v<n>), remembers it inpayments.discount, and sendsdiscounts: [{ coupon }]next toautomatic_tax. It never makes a promotion code. A one-off payment acceptsonceonly; bad terms are refused before any call (PAYMENTS_BAD_DISCOUNT). - Idempotency, metadata, deadline.
idempotencyKeyis Stripe's header;metadatais echoed on the paid event (account_idandkindare the adapter's own);expiresAtbecomes the session'sexpires_at, refused outside the processor's window (PAYMENTS_BAD_EXPIRY). - Events.
payment_paidandsubscription_startedcarrycheckoutRef,paymentRef,invoiceRef,amountTotal,amountDiscount,currency,metadata.checkout_expired(release a reservation) andpayment_refunded(amountRefundedis cumulative; the payment is named bypaymentRef, and byinvoiceReffor a subscription) are translated too. A fully discounted checkout (no_payment_required) is a paid one withoutpaymentRef. - Webhooks are verified and idempotent.
Stripe-Signatureis checked in constant time over the raw body inside a replay window (the admin's setting).applyOnceInTransactionwrites the event id and runs the handler in one transaction: a handler that throws leaves the event unrecorded and the retry applies it; many requests at once apply it exactly once (tested on PostgreSQL).applyOnce(id first,forgeton failure) stays for handlers with no database effect. - Through the outbox.
announcePayment(tx, outbox, event)appendsevt.payment.<fact>.v1to@krizaka/outboxin the handler's transaction, with an id derived from the processor's event id: announced once, delivered at least once. - Tables (
paymentsSchema, Postgres schemapayments):event(the record of applied events) anddiscount. A product that haspublic.payment_events(Yomizaka migration 0009, Akinaza 0010) runsadoptPaymentEventsSql()once, which moves the table with its rows intopayments.event, then emits version 1. Until thenpgEventStore(query)readspublic.payment_events. - Settings (
@krizaka/payments/settings):payments.webhookToleranceSeconds(300, 30–900). - Catalogue setup.
krizaka-payments-setup --catalogue catalogue.json [--webhook-url …] [--secret-out file]creates, idempotently, products, prices (tax exclusive), the customer portal and the webhook endpoint (now listening tocheckout.session.expiredandcharge.refundedtoo; an older endpoint is updated). A changed amount is a new price that takes over the lookup key (transfer_lookup_key), so subscriptions already sold keep theirs.plansFromPrices(entries, names)builds the plans from the settings price catalogue: what is on sale, with a figure, inmonth/year/once. - Formatting.
formatMoney/formatPrice: « $39 » in English, « 39 $ » in French; decimals only when there are some; any currency's exponent. eventStoreContractanddiscountStoreContract(@krizaka/payments/testkit) are the contracts every store runs;memoryEventStore()andmemoryDiscountStore()are for unit tests.
Connect: the merchant's own processor (Stripe Connect Standard)
For a product whose customers are merchants (a shop taking its own clients' payments). Each shop connects its own Stripe account through Stripe-hosted onboarding; every charge is a direct charge on that account (Stripe-Account): the shop stays the merchant of record and Krizaka never holds the money. The library never reads env: the app passes the platform key, the webhook secret, the clock and the stores.
const connect = connectAdapter({ secretKey, fetch }); // ConnectPort
const { accountId } = await connect.createAccount({ key: shopId, email, country: "ca" });
const { url } = await connect.onboardingLink({ accountId, returnUrl, refreshUrl }); // redirect the owner
const status = await connect.accountStatus(accountId); // chargesEnabled, detailsSubmitted, state: pending | restricted | active
const { customerId } = await connect.createCustomer(accountId, { key, email });
const { clientSecret } = await connect.saveCard(accountId, { key, customerId }); // the browser confirms it with the processor's form
const deposit = await connect.pay(accountId, {
key: bookingId,
customerId,
paymentMethodRef,
amountMinor: 2000,
currency: "cad",
mode: "hold",
});
await connect.capture(accountId, deposit.paymentRef, { key: `${bookingId}:cap`, amountMinor: 1500 }); // or connect.release(...)
await connect.refund(accountId, deposit.paymentRef, { key: `${bookingId}:ref`, amountMinor: 500 });- No card data. There is no card number, CVC or expiry field anywhere in the port: a card is saved by the processor's own form (
saveCardreturns theclientSecretonly) and used by reference. - Money is integer minor units;
mode: "capture"takes the payment,"hold"authorises it (a deposit kept as a guarantee) untilcapture(all or part) orrelease.applicationFeeMinoris the platform's optional fee (0 by default, below the amount, elsePAYMENTS_BAD_FEE). - A refund never exceeds what was paid: the adapter reads the payment and refuses above paid minus already refunded (
PAYMENTS_REFUND_EXCEEDS_PAID), or a payment never captured (PAYMENTS_NO_PAYMENT). Every call takes an idempotencykey. - ONE Connect webhook on the platform account (created once in config, its secret in the env; shops declare nothing):
handleConnectWebhook({ secret, clock, store, handlers }, rawBody, signatureHeader)verifies the signature, routes by the event'saccount, applies each event once (store.markSeen, a replay isduplicate, a failing handler gives the id back).handlers(account_updated,payment_paid,payment_held,payment_released,payment_refunded, the refund total being cumulative) are the only writers of paid / refunded: a route never marks a booking paid itself. A notification without anaccountis the platform's own and is ignored. - What a product must show the client before confirming a deposit or a card on file: the amount, the cancellation and no-show policy, and the refund rules (Québec Consumer Protection Act; reviewed in the single lawyer review, krizaka/.github#250). The no-show fee is the product's decision (policy id and reason in its own audit); the library only moves the money.
- Pre-authorised debit (PAD, Canada) is a later adapter step behind the same port.
Migrating from krizaka-ui's 0.1/0.2
stripeAdapter and StripeAdapterConfig are kept as aliases of processorAdapter and ProcessorConfig. checkout now answers { url, checkoutRef, expiresAt }. payment_paid and subscription_started gained fields (never removed). applyOnceInTransaction writes to payments.event by default: pass { table: { schema: "public", table: "payment_events" } } until adoptPaymentEventsSql has run. Environment: PAYMENTS_PROVIDER, PAYMENTS_STRIPE_SECRET_KEY, PAYMENTS_STRIPE_WEBHOOK_SECRET, as today (read by the product, never here).
Testing a Connect app
memoryConnect() is the in-memory ConnectPort for your tests: same refusals as connectAdapter (typed PaymentsError), idempotent per key, and a deposit capture racing a release is serialised so exactly one wins and the other is refused with PAYMENTS_NOT_HELD. activate(accountId) plays the end of onboarding.
An opt-in contract test runs the real adapter against the processor's test mode: set PAYMENTS_CONTRACT_KEY to a test-mode key (sk_test_… only; any other value is ignored) and run pnpm --filter @krizaka/payments test. Default CI never sets it.
Exports
Les 76 noms que le paquet exporte, lus dans ses types.
| Nom | Nature | Ce que c’est |
|---|---|---|
addMoney | fonction | The sum of amounts of one currency. |
adoptPaymentEventsSql | fonction | The SQL that moves a product's existing payment_events into the module's schema as event, keeping every row, so version 1 finds it there. |
announcePayment | fonction | Announces a payment event through the outbox, INSIDE the handler's transaction: the announcement commits with the record of the event and the product's own e… |
applyOnce | fonction | Applies a verified event once: its id is recorded first, a replay runs no handler. |
applyOnceInTransaction | fonction | Applies a verified event once, recording its id and running its handler in one transaction: both commit together, or both roll back when the handler thro… |
ApplyOptions | type | |
assertCharge | fonction | Checks a charge before any call. |
assertTerms | fonction | Checks the terms a discount is made of, before any call. |
assertTrial | fonction | Checks the free days of a checkout: whole days within the processor's bounds, and a subscription only. |
Catalogue | type | |
CataloguePlan | type | One product and price a product sells, as data. |
ChargeRequest | type | A one-off charge of an amount the product computed (a provider's cost plus a margin, say), on a customer who already holds a payment method. |
ChargeResult | type | A charge that went through: the invoice reference to refund by, the payment reference when the processor gave one, and what was taken. |
CheckoutRequest | type | |
Clock | type | |
ConnectAccountStatus | type | What the product reads of a merchant's account. |
connectAdapter | fonction | Stripe Connect Standard over fetch: direct charges on the connected account, no SDK. |
ConnectAdapterConfig | type | |
ConnectEvent | type | A notification of the Connect webhook, in the domain's words, always with the merchant's account. |
ConnectHandlers | type | The only writers of paid / held / released / refunded and of the account's state: the product's handlers, called once per verified notification. |
ConnectOutcome | type | |
ConnectPayment | type | |
ConnectPaymentState | type | A payment's state at the processor, in the domain's words. |
ConnectPort | type | |
ConnectWebhookConfig | type | |
dbDiscountStore | fonction | The PostgreSQL memory of the processor's objects made for a discount's terms: payments.discount. |
dbEventStore | fonction | The PostgreSQL record of applied notifications, payments.event (id text primary key, type text not null, received_at timestamptz default now()) (see `payme… |
DEFAULT_TOLERANCE_SECONDS | constante | The processor's documented replay tolerance for a webhook timestamp, in seconds; the admin's payments.webhookToleranceSeconds overrides it. |
DirectPaymentRequest | type | |
DiscountStore | type | Where the processor's object made for a discount's terms is remembered, so one (couponId, termsVersion) makes one object. |
DiscountTerms | type | The terms of a discount, vendor-free and IMMUTABLE for a (couponId, termsVersion): a change of terms is a new version, so the processor's object made for a… |
ensureCatalogue | fonction | Creates, idempotently, what the adapter reads as DATA: products and prices (found by lookup key), the customer-portal configuration and, when asked, the webh… |
EventStore | type | Where the ids of the notifications already applied are kept (one line per notification). |
formatMoney | fonction | An amount in the visitor's language: « $39 » in English, « 39 $ » in French (Canadian conventions); decimals only when there are some. |
formatPrice | fonction | Same as {@link formatMoney}, for a processor's {@link Price}. |
formEncode | fonction | The processor's form encoding: nested keys as a[b][0][c]=v. |
handleConnectWebhook | fonction | Verifies the signature over the raw body, translates, and applies the event ONCE through the product's handler. |
memoryConnect | fonction | |
MemoryConnect | type | The in-memory Connect processor of the apps' tests: the same port as connectAdapter, the same refusals, no network. |
memoryDiscountStore | fonction | An in-memory memory of the processor's discount objects, for unit tests. |
memoryEventStore | fonction | An in-memory record of applied notifications, for unit tests (same behaviour as the database's). |
messageIdOf | fonction | A stable UUID from the processor's event id: announcing the same event twice is one outbox message. |
money | fonction | Makes an amount, refusing what is not money: a float, a negative, an unsafe integer, a currency that is not three letters. |
Money | type | An amount: integer minor units (cents) and a lowercase ISO 4217 currency. |
OutboxPort | type | The part of @krizaka/outbox's Outbox this package needs: structural, so payments does not depend on outbox. |
Outcome | type | |
PaidFacts | type | The facts every paid event carries: what was charged, what the discount took off, and the references to find the checkout again. |
PAYMENT_TOPIC | constante | What a payment event is announced as to the rest of the product: the outbox topic and the evt.payment.<fact>.v1 routing keys. |
PaymentEvent | type | What the domain learns from a processor, vendor-free. |
PAYMENTS_CODE | constante | |
PaymentsCode | type | |
PaymentsError | classe | A refusal of this package: the HTTP status a route may answer, a stable code, a message. |
PaymentsPort | type | |
paymentsSchema | constante | The tables of « payments », in their own Postgres schema (ADR-001 §3). |
pgEventStore | fonction | The record as Yomizaka and Akinaza have it today, public.payment_events: for a product that has not moved it yet (see adoptPaymentEventsSql). |
PlanConfig | type | How a plan is bought: a one-off payment or a subscription, and where its price is found. |
PlanKind | type | |
plansFromPrices | fonction | The plans to create at the processor, from the product's price catalogue (ADR-009: prices are data, read through settings). |
Price | type | The processor's price of a plan: integer minor units (cents), a lowercase ISO currency, the billing interval (null = one-off). |
PriceEntryLike | type | The part of a settings price entry (@krizaka/settings' PriceEntry) that the catalogue needs: structural, so payments does not import settings. |
processorAdapter | fonction | The Stripe adapter of the payments port. |
ProcessorConfig | type | |
Query | type | |
rawCall | fonction | One HTTP call to the processor: no SDK, the caller's fetch. |
RefundOptions | type | |
SetupResult | type | |
signPayload | fonction | A signed header — for tests and a setup self-check. |
stripeAdapter | fonction | |
StripeAdapterConfig | type | |
stripeCall | fonction | The processor's API as one function (errors say which call failed), and the mode its key is in. |
StripeCall | type | |
Transaction | type | Opens a transaction, runs the work with its Query, commits when the work returns and rolls back when it throws (transaction of @krizaka/db over the pro… |
TransactionHandlers | type | Handlers that write through the transaction they are given, so their effect and the event's record commit together. |
translate | fonction | The processor's event, in the domain's words. |
translateConnect | fonction | A notification of the ONE platform Connect endpoint: routed by its account. |
verifySignature | fonction | Checks Stripe-Signature (t=…,v1=…) over the raw body, constant-time, inside a replay window. |
@krizaka/payments/settings
| Nom | Nature | Ce que c’est |
|---|---|---|
PAYMENTS_BOUNDS | constante | The bounds of the webhook timestamp tolerance, in seconds. |
paymentsSettings | constante | The settings of « payments », a fragment the product adds to its registry (@krizaka/payments/settings). |
readPaymentsSettings | fonction | The tolerance as processorAdapter takes it, from the product's accessor. |
@krizaka/payments/testkit
| Nom | Nature | Ce que c’est |
|---|---|---|
discountStoreContract | fonction | What every DiscountStore must do: one object per (coupon, terms version, provider), the first remembered wins. |
eventStoreContract | fonction | What every EventStore must do: record an id once, answer a replay false, give an id back so a retry applies it. |
Paquet
| Version sur npm | 0.6.0 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dépendances | @krizaka/db workspace:^, @krizaka/intl ^0.4.1, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Dépendances à fournir | vitest >=4, zod >=3.23 |
| Code source | payments |
Journal des versions
0.7.0
Minor changes
- Stripe Connect Standard for products whose customers are merchants:
connectAdapter(connected-account creation, Stripe-hosted onboarding link, account status, customers and SetupIntent card-saving on the connected account, direct charges and deposit holds with capture or release, refunds capped at what was paid, optional application fee), andhandleConnectWebhook, the one platform Connect webhook (signature verified, events routed byaccount, applied once, handlers are the only writers of paid / refunded). Integer cents, injected clock, typedPaymentsErrorcodes, no card data in any input, and the library never reads env.
0.6.0
Minor changes
- A subscription checkout can start with free days (
CheckoutRequest.trialDays, the processor's trial, 1 to 730 whole days, refused on a one-off payment), and the adapter declarescapabilities.trialso a product refuses to promise free days on an adapter that would drop them. The product decides who gets a trial and in which mode of the processor.
0.5.0
Minor changes
subscription_changedcarriescancelAtPeriodEnd: true while the subscription is active but will not renew (the customer cancelled; the processor'scancel_at_period_end), false once it has ended. A product shows « Ends on <periodEnd> » instead of « Renews on ».
0.4.2
Patch changes
- Updated dependencies [2a7898c]
- Updated dependencies [2272b57]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.4.1
Patch changes
- Updated dependencies [0d19b64]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.4.0
Minor changes
- One-off per-item charge (#132):
PaymentsPort.charge({ key, customerId, amountMinor, currency, description, taxCode })makes one invoice with one taxed line (Stripe Tax, tax behaviour exclusive), finalises and pays it with the saved payment method, every call keyed bykey; a declined payment voids the invoice and throwsPAYMENTS_DECLINED.PaymentsPort.refund(ref, { key, amountMinor })gives it back, by invoice or payment reference (PAYMENTS_NO_PAYMENTwhen an invoice holds none).assertCharge. A product's own fake ofPaymentsPortgains the two methods.
Patch changes
- Updated dependencies [8210a9a]
- @krizaka/[email protected]
0.3.0
Minor changes
@krizaka/paymentsmoves to krizaka-ts (#55), same npm name, 0.2.2 to 0.3.0; krizaka-ui stops publishing it. The processor stays behindPaymentsPort.checkouttakesdiscount(DiscountTerms: one processor coupon per(couponId, termsVersion), remembered inpayments.discount, sent asdiscountsnext to automatic tax, never a promotion code),idempotencyKey,metadataandexpiresAt(31 minutes to 24 hours), and answers{ url, checkoutRef, expiresAt }. Events:checkout_expiredandpayment_refunded(cumulative) are translated;payment_paidandsubscription_startedcarrycheckoutRef,paymentRef,invoiceRef,amountTotal,amountDiscount,currency,metadata.applyOnceInTransactionis the way (record and effect in one transaction,payments.event);adoptPaymentEventsSqlmoves a product'spayment_events;announcePaymentappendsevt.payment.*.v1to the outbox. Also:money,formatMoney,paymentsSchema,@krizaka/payments/settings(payments.webhookToleranceSeconds),@krizaka/payments/testkit,plansFromPrices, repricing throughtransfer_lookup_key, and the setup's webhook now listens to expired and refunded.
@krizaka/settings
La configuration comme donnée : un registre typé, utilisateur avant espace avant global, et le catalogue des prix en versions datées ; chaque changement audité.
@krizaka/coupons
Des coupons de rabais et de crédits gérés par l’admin : réservés sous verrou, confirmés une fois, libérés si non payés, remboursés ; des partenaires pour l’attribution.