@codefusion-cc/commerce-payments
v0.2.5
Published
Payment gateways for a CodeFusion shop: the gateway contract and the verdicts signed notifications become at the root, and one entry per gateway (./payu, ./przelewy24, ./tpay, each with ./<gateway>/browser for its panel definition), so a Worker bundles on
Maintainers
Readme
@codefusion-cc/commerce-payments
Payment gateways for a CodeFusion shop: Przelewy24, PayU and Tpay. Each gateway does three things:
- starts a payment and returns where to send the customer,
- refunds through its own API,
- turns its signed notifications into a verdict.
The shop acts on the verdict. It keeps its payments table, checks the amount against the order, marks it paid, and sends mails and alerts. So the gateways never touch a database and are the same for every shop.
npm install @codefusion-cc/commerce-paymentsEntry points
A shop imports only the gateways it uses, so its Worker bundles no other gateway's code (the rule for every commerce kind: ROADMAP.md, Commerce).
| Entry | Holds |
| --- | --- |
| @codefusion-cc/commerce-payments | The gateway contract: PaymentGateway, PaymentStart, RefundRequest, the verdicts (Notification), NOTIFICATION_MAX_BYTES, and the OAuth, refund (refundOrder, refundAnswerError, RefundRefused) and notification helpers a gateway builds on. No gateway. |
| …/browser | What every gateway's definition shares, for the panel's page and the storefront: PAYMENT_GROUP, gatewayDef, isSandbox, modeLabel. |
| …/payu, …/przelewy24, …/tpay | The gateway (payu, przelewy24, tpay), its signature helpers, and its definition. |
| …/payu/browser, …/przelewy24/browser, …/tpay/browser | The gateway's panel definition alone (payuDef, przelewy24Def, tpayDef), without its code. |
| …/testing | A fake of all three gateways' APIs and their signatures. |
// The Worker: the gateways the shop offers, each from its own entry.
import type { PaymentGateway } from '@codefusion-cc/commerce-payments'
import { payu } from '@codefusion-cc/commerce-payments/payu'
import { przelewy24 } from '@codefusion-cc/commerce-payments/przelewy24'
export const GATEWAYS: PaymentGateway[] = [przelewy24, payu]
// The panel's page and the storefront: the definitions, without the gateways' code.
import { isSandbox, PAYMENT_GROUP } from '@codefusion-cc/commerce-payments/browser'
import { payuDef } from '@codefusion-cc/commerce-payments/payu/browser'
import { przelewy24Def } from '@codefusion-cc/commerce-payments/przelewy24/browser'Using a gateway
// The gateway plugin's context (@codefusion-cc/commerce) as it is: its config, secrets and fetch.
const { redirectUrl, providerId } = await gateway.start(ctx, { sessionId, amount, description, email, name, phone, returnUrl, notifyUrl, customerIp })
// /api/hooks/<id>/notify
const verdict = await gateway.notifications.notify(ctx, body, request.headers)
switch (verdict.kind) {
case 'paid': // check sessionId and amount against your payment; if not paid yet: await verdict.confirm?.(), then mark it paid
case 'failed': // the customer may pay again
case 'refund': // a refund's outcome (Przelewy24, PayU): verdict.refundId is the ref refund() answered with; ok false: not made
case 'warning': // tell the owner (PayU waiting for capture)
case 'ignored':
return gateway.ack() // what the gateway expects back
case 'refused': // bad signature or data: answer 400 and tell the owner (verdict.field: the setting to check)
}Refunds
try {
const { ref } = await gateway.refund!(ctx, { payment: { sessionId, providerId, gatewayRef, amount }, amount, refundId, description, notifyUrl })
} catch (error) {
// A ProviderError. uncertain false: the gateway surely made no refund (it refused, or the order never left), so the
// amount may go back to what can be refunded. uncertain true (no answer, a 5xx, an answer that confirms nothing):
// the refund may have been made. Keep the amount reserved and settle it from the gateway's panel or its refund
// notification before ordering it again.
}Keep ref with the refund: the refund verdict of the gateway's notification carries it as refundId. It is the
refundId the shop sent, for every gateway here. Przelewy24 and PayU refuse a second order with that id; Tpay's API
takes none, so a Tpay refund that came out uncertain is checked in Tpay's panel before it is ordered again.
A gateway built on this package sends its refund order through refundOrder, which tells the two apart, and words an
answer that is not a success with refundAnswerError, or a refusal inside a successful answer with RefundRefused. Once
the order may have been taken, any other error comes out uncertain.
Every request to a gateway gives up after GATEWAY_TIMEOUT_MS (10 s) without an answer, as a dropped connection does:
starting a payment fails with an error, a refund order comes out uncertain, and a notification that needed a request
(Przelewy24's verify, Tpay's certificate) fails, so the gateway sends it again.
PayU's and Tpay's OAuth tokens are kept in the Worker's memory until shortly before they run out (cachedToken in
@codefusion-cc/commerce), so a checkout does not wait for a new one. The panel's check (test) always asks for a new
token, and a token the gateway refuses is replaced once.
The notification body is at most NOTIFICATION_MAX_BYTES, so read it with a limit (readBody in
@codefusion-cc/workers-http). Signatures use MD5 (PayU, Tpay), which only Workers' WebCrypto has, so the gateways run
in Workers.
Each definition (field labels, hints, steps) is in Polish, and so are the errors the owner sees. These gateways serve Polish shops, and the text is the same for every shop.
Paying an order
The gateway talks to the provider and checks what it signed; the shop's side of a payment is in the root entry and works
on the shop's D1 database (the orders of @codefusion-cc/commerce). Its tables, order_payments and order_payment_refunds, are
this package's migrations/, which codefusion-commerce-migrations copies into the shop next to the commerce ones.
startOrderPaymentstarts an attempt for the order with an access token. The amount is the order's total, copied by the statement that writes the attempt, never taken from the customer. One start per order at a time (busyfor a second), a pending attempt under two minutes old, of the same gateway and for the order's total as it is now, is handed out again (a double click makes one transaction), and the gateway's failure (an error, a timeout, a body that is not JSON, an address that is not https) is agateway_failedresult with the attempt marked failed. Once the gateway has the transaction, a database that cannot record it throws, and the attempt blocks a second transaction for a minute.handleOrderNotificationgives the request to the gateway first (signature, merchant, currency); only a verdict reaches the database. A payment is believed once: the attempt becomespaidand the order moves topaidthroughorderStatusStatementsin one batch, only while it isawaiting_paymentand its total is still the attempt's amount. A replay, a notification out of order (pending or cancelled after paid), another amount or transaction, an unknown attempt, a cancelled order: none moves an order backwards or pays it twice. The result is the response for the gateway and aPaymentEventfor the shop's mail, notes and logs (paidis the one that sends "payment received", with what came, so more than the amount is an overpayment to give back;paid_elsewhereis money for an order that cannot take it, to refund, whichelsewhereNotewrites on the order in the same transaction;underpaidandtest_paymentsay withfirstwhether this notification is the one to write a note on;refundis a refund this notification just settled, with what the shop held it for). A failing database throws, so the route answers 500 and the gateway sends the notification again.testPaysOrdersfalse (a production shop) keeps the gateway's sandbox from paying orders; a notification the gateway marks as a test on a production configuration pays nowhere, and an attempt once held as a test stays one.refundOrderPaymentwrites the refund before asking the gateway, in a statement that refuses more than is left of the payment. Only an answer that proves no refund was made frees the amount; any other failure leaves itpendingand reserved, and the gateway's refund notification (the same webhook) settles it: made, or cancelled (also after the gateway first took it) and free again.orderPaymentStateis what the order page asks: the order's status,none,pending,paid,failedortest, and whether the customer may pay now (method: the order's way of paying that means this gateway).
The tables are the shop's record of money: an order that has a payment cannot be deleted, nor a payment that has a
refund (ON DELETE RESTRICT).
The shop supplies its routes (the notification route must not require the page's origin), the gateway's keys, the return page, and what to say or send on each event.
A new gateway
A gateway is an entry of this package: src/<gateway>/index.ts (the PaymentGateway, built on the root's contract) and
src/<gateway>/browser.ts (its definition, made with gatewayDef), both listed in exports. It becomes a package of
its own (@codefusion-cc/commerce-payments-<gateway>, depending on this one for the contract) only when it needs a
heavy dependency, is maintained by someone else, or needs its own release pace.
Testing
@codefusion-cc/commerce-payments/testing fakes the gateways' APIs, answering as their documentation does. It stays one
entry for every gateway: tests never reach a Worker's bundle, and one fake lets a shop's tests send every gateway's
requests to one place.
fakeGatewayApi()gives afetchand the requests it received.startFakeGatewayServer()runs an HTTP server, for a Worker under test that sends its calls to<url>/<host><path>.
It also signs notifications the way each gateway does: p24Sign, payuSignature, tpayMd5 and tpayJws. Tpay's
notification certificate is a test certificate made once for this purpose, and only the fake serves it.
Tokens are kept between test cases too: call forgetToken() (@codefusion-cc/commerce) in afterEach when a case counts
the gateway's requests.
