payload-payment-shared
v0.2.0
Published
Shared utilities, order-data builder and errors for PayloadCMS payment adapters (CorvusPay, Comgate, COD, MiniMax, Money S5)
Maintainers
Readme
payload-payment-shared
Shared utilities, order-data builder and errors for PayloadCMS payment adapters. Consolidates code that otherwise drifts across adapter packages (CorvusPay, Comgate, COD, MiniMax, Money S5) into a single source of truth.
Host-agnostic by design: the package never imports an app's generated
payload-types, has no runtime dependencies, and takes collection slugs as
runtime strings. payload is the only peer dependency.
Install
pnpm add payload-payment-sharedPeer deps: payload@^3.81.0, @payloadcms/db-postgres@^3.81.0.
All exports are at the root:
import { PaymentError, buildOrderDataFromTransaction } from 'payload-payment-shared'The package ships dual ESM/CJS via tsup plus types at dist/index.d.ts.
Exports
Errors
| Export | Kind | Summary |
| --- | --- | --- |
| PaymentError | class | The only payment error class. Stable code taxonomy, dynamic details, native cause, plain-string message. |
| PaymentErrorOptions | type | Constructor options. |
code is machine-readable and stable (REFUND_FAILED, SIGNATURE_INVALID,
CART_CURRENCY_MISMATCH, …) — group on it in Sentry, and never put dynamic
values in it. Dynamic payload goes in details.
Order creation
| Export | Kind | Summary |
| --- | --- | --- |
| buildOrderDataFromTransaction | function | Builds the orders create payload from a paid transaction. Pure — no reads, no writes. |
| DEFAULT_ORDER_STATUS | const | 'processing' — the status a confirmed order is created with by default. |
| OrderItemPricingResolver | type | Injection point for host-specific line pricing. |
| BuildOrderDataOptions, OrderDataFromTransaction, OrderSourceTransaction, OrderSourceCart, OrderSourceCartItem, OrderItemData, OrderAddressSnapshot, OrderDiscountSnapshot, OrderShippingMethodSnapshot, OrderOssFields, OrderInvoiceType, OrderCurrency | types | The order/transaction shapes the builder maps between. |
Order creation exists twice per gateway — the adapter's confirmOrder (browser
return) and the gateway webhook (server-to-server) — and the webhook usually
wins the race. Two hand-written copies drift, and what they drop is consents,
pricingContext and cart-written line prices on live paid orders. This builder
is the one mapping both call:
const data = buildOrderDataFromTransaction({
transaction, // read at depth 2 so cart items carry their products
fallbackCurrency: 'EUR', // when the transaction carries none
customerId, // resolved by the caller (lookups are async)
transactionId, // links the payment onto the order
})
await payload.create({ collection: ordersSlug, data, req })It is generic over the currency and status unions, so a host whose orders
collection declares narrower enums gets its own literal types back and
payload.create typechecks with no cast:
buildOrderDataFromTransaction<'EUR' | 'CZK', 'processing'>({ ... })Three seams keep host-specific behaviour out of the package:
resolveItemPricing— by default each line is re-derived from the catalog byresolveOrderItemPricing. A host whose cart carries server-written prices that must NOT be re-derived (bundle apportionment, B2B contract prices) passes its own resolver; it receives the raw cart line and can trustitem.priceAtPurchase.destination— cross-bordernet × (1 + VAT)pricing for b2c lines. Omit for home deliveries so list prices pass through untouched.status— the post-payment state, when it isn't'processing'.
Host-specific order groups are spread on by the caller — the result is a plain object.
Pricing
| Export | Kind | Summary |
| --- | --- | --- |
| resolveOrderItemPricing | function | Line unit price in minor units + originalPrice when a sale undercuts the regular price. Reads priceIn{CUR} / salePriceIn{CUR} (gated by saleEnabled) / b2bPriceIn{CUR} / netPriceIn{CUR}. |
| resolveOrderItemPrice | function | .price of the above, for callers that only need the charged amount. |
| calcSubscriptionDiscountCents | function | Subscription line discount, in cents. |
| PricingContext | type | 'b2c' (retail, honours salePrice) | 'b2b' (wholesale tier, never a consumer discount). |
| ItemDestination, ResolvedItemPricing | types | Cross-border destination descriptor and the resolver result. |
Checkout consents
| Export | Kind | Summary |
| --- | --- | --- |
| extractCheckoutConsents | function | Pulls note / newsletter opt-in / survey opt-OUT / terms stamp out of client-supplied additionalData, validated and length-capped. |
| consentsFromTransaction | function | Re-reads the same consents off a persisted transaction, for the order create. |
| CUSTOMER_NOTE_MAX_LENGTH | const | 500 — the server-side authority for the note length. |
| CheckoutConsents | type | The consent subset. |
Nothing is trusted: only an explicit true counts as consent, and the terms
stamp is taken only as a complete, parseable (timestamp, version) pair — half
a consent record is logged and dropped rather than written as legal evidence.
Guards
| Export | Kind | Summary |
| --- | --- | --- |
| assertExpectedCurrency | function | Throws CART_CURRENCY_MISMATCH when the cart's currency differs from the currency the storefront displayed to the payer. |
| withPaymentLock | function | Advisory lock around order creation, so a webhook and a browser return cannot both create one. |
| resolveCustomerId | function | Secure-by-default customer lookup: authed user → id, guest → null unless allowEmailLookup is passed. |
| resolvePaymentMode | function | Resolves 'mock' \| 'live' from config + env, refusing mock in production unless ALLOW_MOCK_PAYMENTS=1. |
| isMockSignatureValue | function | The signature === 'mock' check at verification call sites. |
| AssertExpectedCurrencyOptions, ResolveCustomerIdOptions, PaymentMode, MockModeInput | types | |
The ecommerce plugin resolves the charge currency from cart.currency, so a
stale cart created under another storefront can silently charge in one currency
while the payer saw prices in another. Fail loudly; the storefront reprices and
the payer retries.
resolveCustomerId returns null for guests on purpose — without it, a guest
checkout using someone else's email silently links the new order to that
person's account.
Payload plumbing
| Export | Kind | Summary |
| --- | --- | --- |
| extractRelationId | function | A relationship's raw id, whether it came back populated or as a bare id. undefined — never null — when absent, so it drops out of an optional-property spread. |
| extractRelation | function | The populated document, or null when the relation is a bare id. |
| asTransactionDoc | function | Runtime-guarded narrowing of an opaque Payload doc (TRANSACTION_DOC_INVALID). |
| asTransactionCollection | function | Narrows a runtime slug string to the literal CollectionSlug Payload's calls demand. Compile-time only, zero runtime effect. |
| asOrderId | function | The order relation as a number, throwing ORDER_RELATION_INVALID rather than typing a string id as a number. |
Caching & logging
| Export | Kind | Summary |
| --- | --- | --- |
| createCache | function | In-memory TTL cache factory (get, set, delete, clear). |
| DEFAULT_TTL_MS | const | 3_600_000 (1 hour). |
| consoleLogger, noopLogger | const | PaymentLogger implementations for standalone clients, scripts and tests. |
| TtlCache, PaymentLogger | types | |
Prefer req.payload.logger (pino) wherever req is in scope — it gives
structured JSON, request correlation and secret redaction. Inject a
PaymentLogger only where there is no req (stateless API clients, refund
helpers).
Statuses & shared unions
| Export | Kind | Summary |
| --- | --- | --- |
| SETTLED_ORDER_STATUSES | const | Statuses that are settled or void — nothing in the set counts toward an outstanding balance or can be overdue. |
| SettledOrderStatus, Currency, TransactionStatus | types | |
| PaymentSuccessResult, PaymentInitResult | types | What confirmOrder / initiatePayment return to the client. |
Scripts
pnpm build # tsup -> dist/ (esm + cjs + d.ts)
pnpm typecheck # tsc --noEmit
pnpm test # vitest runDon'ts
- Don't reintroduce a local
PaymentErrorclass inside an adapter. Theinstanceof PaymentErrorcheck MUST work across adapter boundaries. - Don't JSON-stringify the
PaymentErrormessage. Keeperr.messagea plain string; put dynamic values indetails. - Don't store
nullin the TTL cache as "absent" — usedelete(key)orset(key, undefined). A literalnullreads as "cache hit, empty" for the whole TTL. - Don't read
process.env.PAYMENT_MOCK_MODEdirectly — go throughresolvePaymentMode()so the production guardrail applies. - Don't email-lookup customers by default (account takeover risk).
- Don't re-derive a line price the cart wrote server-side — pass
resolveItemPricinginstead, or the discount is silently undone andΣ(items) ≠ grandTotalon the ERP push.
Releasing
Bump the version, update CHANGELOG.md, commit, then push a tag:
git tag v0.2.0 && git push origin v0.2.0The release workflow builds, tests and runs
npm publish --provenance --access public with the NPM_TOKEN secret.
License
MIT © blaze IT s.r.o.
