Aller au contenu
Krizaka TypeScript
Krizaka TypeScript

@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

bashTerminal
npm install @krizaka/payments

Aussi 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/settings price 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 by key. A declined payment voids the invoice and throws PAYMENTS_DECLINED; refund(ref, { key, amountMinor }) takes the invoice or payment reference.
  • Discount terms. CheckoutRequest.discount is DiscountTerms (couponId, termsVersion, percentOffBp basis points or amountOffMinor + currency, duration once / repeating / forever). The Stripe adapter makes one Stripe coupon per (couponId, termsVersion) (idempotency key coupon-<id>-v<n>), remembers it in payments.discount, and sends discounts: [{ coupon }] next to automatic_tax. It never makes a promotion code. A one-off payment accepts once only; bad terms are refused before any call (PAYMENTS_BAD_DISCOUNT).
  • Idempotency, metadata, deadline. idempotencyKey is Stripe's header; metadata is echoed on the paid event (account_id and kind are the adapter's own); expiresAt becomes the session's expires_at, refused outside the processor's window (PAYMENTS_BAD_EXPIRY).
  • Events. payment_paid and subscription_started carry checkoutRef, paymentRef, invoiceRef, amountTotal, amountDiscount, currency, metadata. checkout_expired (release a reservation) and payment_refunded (amountRefunded is cumulative; the payment is named by paymentRef, and by invoiceRef for a subscription) are translated too. A fully discounted checkout (no_payment_required) is a paid one without paymentRef.
  • Webhooks are verified and idempotent. Stripe-Signature is checked in constant time over the raw body inside a replay window (the admin's setting). applyOnceInTransaction writes 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, forget on failure) stays for handlers with no database effect.
  • Through the outbox. announcePayment(tx, outbox, event) appends evt.payment.<fact>.v1 to @krizaka/outbox in the handler's transaction, with an id derived from the processor's event id: announced once, delivered at least once.
  • Tables (paymentsSchema, Postgres schema payments): event (the record of applied events) and discount. A product that has public.payment_events (Yomizaka migration 0009, Akinaza 0010) runs adoptPaymentEventsSql() once, which moves the table with its rows into payments.event, then emits version 1. Until then pgEventStore(query) reads public.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 to checkout.session.expired and charge.refunded too; 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, in month / year / once.
  • Formatting. formatMoney / formatPrice: « $39 » in English, « 39 $ » in French; decimals only when there are some; any currency's exponent.
  • eventStoreContract and discountStoreContract (@krizaka/payments/testkit) are the contracts every store runs; memoryEventStore() and memoryDiscountStore() 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 (saveCard returns the clientSecret only) and used by reference.
  • Money is integer minor units; mode: "capture" takes the payment, "hold" authorises it (a deposit kept as a guarantee) until capture (all or part) or release. applicationFeeMinor is the platform's optional fee (0 by default, below the amount, else PAYMENTS_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 idempotency key.
  • 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's account, applies each event once (store.markSeen, a replay is duplicate, 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 an account is 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.

NomNatureCe que c’est
addMoneyfonctionThe sum of amounts of one currency.
adoptPaymentEventsSqlfonctionThe 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.
announcePaymentfonctionAnnounces 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…
applyOncefonctionApplies a verified event once: its id is recorded first, a replay runs no handler.
applyOnceInTransactionfonctionApplies 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…
ApplyOptionstype
assertChargefonctionChecks a charge before any call.
assertTermsfonctionChecks the terms a discount is made of, before any call.
assertTrialfonctionChecks the free days of a checkout: whole days within the processor's bounds, and a subscription only.
Cataloguetype
CataloguePlantypeOne product and price a product sells, as data.
ChargeRequesttypeA 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.
ChargeResulttypeA charge that went through: the invoice reference to refund by, the payment reference when the processor gave one, and what was taken.
CheckoutRequesttype
Clocktype
ConnectAccountStatustypeWhat the product reads of a merchant's account.
connectAdapterfonctionStripe Connect Standard over fetch: direct charges on the connected account, no SDK.
ConnectAdapterConfigtype
ConnectEventtypeA notification of the Connect webhook, in the domain's words, always with the merchant's account.
ConnectHandlerstypeThe only writers of paid / held / released / refunded and of the account's state: the product's handlers, called once per verified notification.
ConnectOutcometype
ConnectPaymenttype
ConnectPaymentStatetypeA payment's state at the processor, in the domain's words.
ConnectPorttype
ConnectWebhookConfigtype
dbDiscountStorefonctionThe PostgreSQL memory of the processor's objects made for a discount's terms: payments.discount.
dbEventStorefonctionThe PostgreSQL record of applied notifications, payments.event (id text primary key, type text not null, received_at timestamptz default now()) (see `payme…
DEFAULT_TOLERANCE_SECONDSconstanteThe processor's documented replay tolerance for a webhook timestamp, in seconds; the admin's payments.webhookToleranceSeconds overrides it.
DirectPaymentRequesttype
DiscountStoretypeWhere the processor's object made for a discount's terms is remembered, so one (couponId, termsVersion) makes one object.
DiscountTermstypeThe 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…
ensureCataloguefonctionCreates, idempotently, what the adapter reads as DATA: products and prices (found by lookup key), the customer-portal configuration and, when asked, the webh…
EventStoretypeWhere the ids of the notifications already applied are kept (one line per notification).
formatMoneyfonctionAn amount in the visitor's language: « $39 » in English, « 39 $ » in French (Canadian conventions); decimals only when there are some.
formatPricefonctionSame as {@link formatMoney}, for a processor's {@link Price}.
formEncodefonctionThe processor's form encoding: nested keys as a[b][0][c]=v.
handleConnectWebhookfonctionVerifies the signature over the raw body, translates, and applies the event ONCE through the product's handler.
memoryConnectfonction
MemoryConnecttypeThe in-memory Connect processor of the apps' tests: the same port as connectAdapter, the same refusals, no network.
memoryDiscountStorefonctionAn in-memory memory of the processor's discount objects, for unit tests.
memoryEventStorefonctionAn in-memory record of applied notifications, for unit tests (same behaviour as the database's).
messageIdOffonctionA stable UUID from the processor's event id: announcing the same event twice is one outbox message.
moneyfonctionMakes an amount, refusing what is not money: a float, a negative, an unsafe integer, a currency that is not three letters.
MoneytypeAn amount: integer minor units (cents) and a lowercase ISO 4217 currency.
OutboxPorttypeThe part of @krizaka/outbox's Outbox this package needs: structural, so payments does not depend on outbox.
Outcometype
PaidFactstypeThe facts every paid event carries: what was charged, what the discount took off, and the references to find the checkout again.
PAYMENT_TOPICconstanteWhat a payment event is announced as to the rest of the product: the outbox topic and the evt.payment.&lt;fact>.v1 routing keys.
PaymentEventtypeWhat the domain learns from a processor, vendor-free.
PAYMENTS_CODEconstante
PaymentsCodetype
PaymentsErrorclasseA refusal of this package: the HTTP status a route may answer, a stable code, a message.
PaymentsPorttype
paymentsSchemaconstanteThe tables of « payments », in their own Postgres schema (ADR-001 §3).
pgEventStorefonctionThe record as Yomizaka and Akinaza have it today, public.payment_events: for a product that has not moved it yet (see adoptPaymentEventsSql).
PlanConfigtypeHow a plan is bought: a one-off payment or a subscription, and where its price is found.
PlanKindtype
plansFromPricesfonctionThe plans to create at the processor, from the product's price catalogue (ADR-009: prices are data, read through settings).
PricetypeThe processor's price of a plan: integer minor units (cents), a lowercase ISO currency, the billing interval (null = one-off).
PriceEntryLiketypeThe part of a settings price entry (@krizaka/settings' PriceEntry) that the catalogue needs: structural, so payments does not import settings.
processorAdapterfonctionThe Stripe adapter of the payments port.
ProcessorConfigtype
Querytype
rawCallfonctionOne HTTP call to the processor: no SDK, the caller's fetch.
RefundOptionstype
SetupResulttype
signPayloadfonctionA signed header — for tests and a setup self-check.
stripeAdapterfonction
StripeAdapterConfigtype
stripeCallfonctionThe processor's API as one function (errors say which call failed), and the mode its key is in.
StripeCalltype
TransactiontypeOpens 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…
TransactionHandlerstypeHandlers that write through the transaction they are given, so their effect and the event's record commit together.
translatefonctionThe processor's event, in the domain's words.
translateConnectfonctionA notification of the ONE platform Connect endpoint: routed by its account.
verifySignaturefonctionChecks Stripe-Signature (t=…,v1=…) over the raw body, constant-time, inside a replay window.

@krizaka/payments/settings

NomNatureCe que c’est
PAYMENTS_BOUNDSconstanteThe bounds of the webhook timestamp tolerance, in seconds.
paymentsSettingsconstanteThe settings of « payments », a fragment the product adds to its registry (@krizaka/payments/settings).
readPaymentsSettingsfonctionThe tolerance as processorAdapter takes it, from the product's accessor.

@krizaka/payments/testkit

NomNatureCe que c’est
discountStoreContractfonctionWhat every DiscountStore must do: one object per (coupon, terms version, provider), the first remembered wins.
eventStoreContractfonctionWhat every EventStore must do: record an id once, answer a replay false, give an id back so a retry applies it.

Paquet

Version sur npm0.6.0 (npm)
LicenceApache-2.0
Node.js>=22
Dépendances@krizaka/db workspace:^, @krizaka/intl ^0.4.1, @krizaka/settings workspace:^, @krizaka/time workspace:^
Dépendances à fournirvitest >=4, zod >=3.23
Code sourcepayments

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), and handleConnectWebhook, the one platform Connect webhook (signature verified, events routed by account, applied once, handlers are the only writers of paid / refunded). Integer cents, injected clock, typed PaymentsError codes, 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 declares capabilities.trial so 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_changed carries cancelAtPeriodEnd: true while the subscription is active but will not renew (the customer cancelled; the processor's cancel_at_period_end), false once it has ended. A product shows « Ends on <periodEnd> » instead of « Renews on ».

0.4.2

Patch changes

0.4.1

Patch changes

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 by key; a declined payment voids the invoice and throws PAYMENTS_DECLINED. PaymentsPort.refund(ref, { key, amountMinor }) gives it back, by invoice or payment reference (PAYMENTS_NO_PAYMENT when an invoice holds none). assertCharge. A product's own fake of PaymentsPort gains the two methods.

Patch changes

0.3.0

Minor changes

  • @krizaka/payments moves to krizaka-ts (#55), same npm name, 0.2.2 to 0.3.0; krizaka-ui stops publishing it. The processor stays behind PaymentsPort. checkout takes discount (DiscountTerms: one processor coupon per (couponId, termsVersion), remembered in payments.discount, sent as discounts next to automatic tax, never a promotion code), idempotencyKey, metadata and expiresAt (31 minutes to 24 hours), and answers { url, checkoutRef, expiresAt }. Events: checkout_expired and payment_refunded (cumulative) are translated; payment_paid and subscription_started carry checkoutRef, paymentRef, invoiceRef, amountTotal, amountDiscount, currency, metadata. applyOnceInTransaction is the way (record and effect in one transaction, payments.event); adoptPaymentEventsSql moves a product's payment_events; announcePayment appends evt.payment.*.v1 to the outbox. Also: money, formatMoney, paymentsSchema, @krizaka/payments/settings (payments.webhookToleranceSeconds), @krizaka/payments/testkit, plansFromPrices, repricing through transfer_lookup_key, and the setup's webhook now listens to expired and refunded.

Sur cette page