Skip to content
v0.3

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.

Each payment app declares a window per method it serves (paymentProvider.reservationWindowSeconds in its manifest). The apps that ship with Forge declare:

AppMethodWindow
payment-mercadopagoPix30 min
payment-mercadopagocard15 min
payment-referencePix30 min
payment-referencecard15 min
payment-zerozero (nothing to charge)15 min
payment-promissorypromissory30 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.

One window, three effects — they cannot disagree, because the kernel computes all three from the same place:

  1. 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.
  2. The countdown the shopper sees: read.payment publishes expires_at while the attempt is open.
  3. The cancellation. Every minute — and once at boot — the kernel cancels each pending_payment order 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.

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.

  • 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-reference simulator’s Pix mode help says never “leaves it pending forever” and that expired stands in for “a clock the kernel does not have”. Since v0.3.3, never keeps the simulated charge from settling, but the kernel still cancels the order when the Pix window passes; expired simulates 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.

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? } (scope extensions.write), read back with read.internal.payment_offers (scope extensions.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.