Payment windows & offers
An order placed with a payment that has not arrived yet sits in awaiting payment (pending_payment). How
long it waits is the payment window of the method the shopper chose, and since v0.3.3 the window is a real
deadline: when it passes, the kernel cancels the order.
Where the window comes from
Section titled “Where the window comes from”Each payment app declares a window per method it serves (paymentProvider.reservationWindowSeconds in its
manifest). The apps that ship with Forge declare:
| App | Method | Window |
|---|---|---|
payment-mercadopago | Pix | 30 min |
payment-mercadopago | card | 15 min |
payment-reference | Pix | 30 min |
payment-reference | card | 15 min |
payment-zero | zero (nothing to charge) | 15 min |
payment-promissory | promissory | 30 days |
An app of your own declares its own. An app that declares no window for a method gives that method no deadline (see “What is never cancelled” below).
Changing it: the window field on the app’s page
Section titled “Changing it: the window field on the app’s page”On each payment app’s page in the admin (Apps ▸ the app ▸ settings) the platform adds one field per method the app declared a window for, labelled “method payment window (minutes)”. No app writes these fields — the platform derives them from the declaration — so every payment app has them, yours included.
- The field’s config name is
<method>_window_minutes(pix_window_minutes,card_window_minutes, …), and it is set per tenant, like the rest of the app’s settings. - It arrives prefilled with the app’s own number. Leave it and nothing changes.
- A value that is not a positive number (emptied,
0, negative) is ignored, and the app’s declaration applies.
What the window drives
Section titled “What the window drives”One window, three effects — they cannot disagree, because the kernel computes all three from the same place:
- The stock hold when the order is placed: units are reserved for the longer of this window and the
store’s own reservation time (Settings ▸ Checkout,
reservation_minutes). The store’s number is a floor on the hold; it does not delay the cancellation. - The countdown the shopper sees:
read.paymentpublishesexpires_atwhile the attempt is open. - The cancellation. Every minute — and once at boot — the kernel cancels each
pending_paymentorder whose live payment attempt has been waiting longer than its window, counted from the latest attempt (a shopper who renewed the code got a fresh clock). Up to 5 000 orders per tenant per pass.
The cancellation is an ordinary one: the stock is released, coupon uses are returned, the order’s timeline says
why (payment_expired), order.cancelled is emitted, and the buyer gets the “order cancelled” message if that
notification is on. order.expire_unpaid runs the same pass on
demand (scope order.write); it is safe and idempotent.
Shortening a window shortens the hold and brings the cancellation forward. There is no switch that turns the pass off; to keep orders waiting longer, raise the window.
Payments from before the expiry never expire (next release)
Section titled “Payments from before the expiry never expire (next release)”Upgrading never changes what is already in your store. From the next release, the pass considers only payments
started after the expiry began running on the tenant: on a box that adopted v0.3.3 that is its v0.3.3
adoption; on a box jumping from v0.3.2 it is the upgrade itself, so the first pass cancels nothing that was
already waiting. An older unpaid order stays awaiting payment until you act on it. order.expire_unpaid
counts such orders as predating, and the kernel logs a line when that count changes. There is no screen for
them: cancel the dead ones by hand, or with your own script through the port.
The count below matters only if you adopt v0.3.3 itself.
Before adopting v0.3.3: count what the first pass cancels
Section titled “Before adopting v0.3.3: count what the first pass cancels”Until v0.3.2 nothing ended an unpaid order, so a box can hold a backlog — and the first pass after adopting
v0.3.3 cancels every order of it already past its window. No read answers that count exactly on v0.3.2 (the
admin’s Awaiting payment tab counts by order age, and includes orders the pass will not touch). Run this
against each tenant schema (select schema_name from forge_control.tenant), after
set search_path to "<schema>", public;:
with pend as ( select so.id, pi.provider_app_id, pi.method, coalesce((select max(pa.created_at) from payment_attempt pa where pa.intent_id = pi.id), pi.created_at) as anchor from sales_order so join payment_intent pi on pi.order_id = so.id and pi.status = 'pending' where so.status = 'pending_payment'),win(provider_app_id, method, seconds) as (values ('payment-mercadopago','pix',1800), ('payment-mercadopago','card',900), ('payment-reference','pix',1800), ('payment-reference','card',900), ('payment-promissory','promissory',2592000), ('payment-zero','zero',900))select p.provider_app_id, p.method, w.seconds, count(*) filter (where p.anchor <= now() - w.seconds * interval '1 second') as cancelled_on_first_pass, count(*) filter (where w.seconds is null) as no_window_listed_here, count(*) as waiting_total from pend p left join win w using (provider_app_id, method) group by 1, 2, 3 order by 1, 2;Add a line to win for each payment app of your own, and use the window field’s value (× 60) where you changed
it. If the count is not what you want cancelled — a promissory backlog you still intend to collect, say — raise
that app’s window before adopting. To keep the “order cancelled” messages from going out, switch that
notification off for the adoption and back on after.
A payment that arrives after the window
Section titled “A payment that arrives after the window”The money is real, so it is not swallowed. If a payment is being confirmed at the moment the pass runs, that
order is skipped and looked at again next minute. A payment confirmed after the order was cancelled leaves
the order cancelled and is reported as a payment.orphaned event, for you to refund or to re-sell by hand.
What is never cancelled
Section titled “What is never cancelled”- An order with no payment started — created with
order.create, imported, seeded. - An order whose payment app is no longer installed, or declares no window for the method. Cancel those
by hand; the pass counts them (
windowless) so the clock that is not running is visible.
⚠️ Texts written before the deadline existed. Two pieces of help text still describe v0.3.2:
- The
payment-referencesimulator’s Pix mode help saysnever“leaves it pending forever” and thatexpiredstands in for “a clock the kernel does not have”. Since v0.3.3,neverkeeps the simulated charge from settling, but the kernel still cancels the order when the Pix window passes;expiredsimulates the provider’s QR code dying before that. - The note beside the window field says a payment arriving after a shortened window finds an order “paid” with
nothing reserved. Since v0.3.3 it finds the order cancelled, and is reported as
payment.orphaned.
This page describes the behaviour.
Which payment apps each store offers
Section titled “Which payment apps each store offers”A payment app is installed once per tenant. Since v0.3.3 each store chooses which of the installed apps it offers, and in which order:
- In the admin: on the payment app’s page, Offered per store.
- Through the port:
payment.offer.set{ store_id, extension_id, offered, position? }(scopeextensions.write), read back withread.internal.payment_offers(scopeextensions.read) — both pages show the result’s shape.
With no choice recorded, every installed app is offered, in install order — what every store had before. A
withheld app is not listed to the shopper, and a checkout that names it anyway is refused with
payment_not_offered.
Going back to v0.3.2 stops applying the choices (every installed app is offered again); they stay stored and apply again when you go forward. See Upgrade & roll back.