Skip to content

Commit 94eee85

Browse files
committed
feat(finance): a payment session hands back the gateway page it is waiting at
1 parent f89f54b commit 94eee85

11 files changed

Lines changed: 264 additions & 38 deletions

File tree

‎api/openapi.gen.yaml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,11 @@ components:
28612861
type: object
28622862
PaymentSession:
28632863
properties:
2864+
checkout_url:
2865+
description: |
2866+
The gateway page an unfinished payment attempt is still waiting at — send the payer straight back to it. Empty when there is nothing to return to: no attempt yet, a rail that redirects nowhere, or a session past its deadline. A client that finds it empty has to ask for a rail first, through `POST /payment-sessions/{id}/payments`.
2867+
example: https://api.shopnexus.io/api/v1/webhooks/payment/sepay/checkout?ref=txn_ev6mj8exxkxwe
2868+
type: string
28642869
created_at:
28652870
format: date-time
28662871
type: string
@@ -2902,6 +2907,7 @@ components:
29022907
- currency
29032908
- total_amount
29042909
- outstanding
2910+
- checkout_url
29052911
- note
29062912
- created_at
29072913
- paid_at
@@ -4081,7 +4087,8 @@ components:
40814087
format: int64
40824088
type: integer
40834089
checkout_url:
4084-
description: Where to send the payer for this leg. Empty once the rail no longer needs it, or for rails that have none.
4090+
description: Where to send the payer for this leg, answered for as long as the leg can still be paid. Empty once it has settled or expired, and for rails that redirect nowhere.
4091+
example: https://api.shopnexus.io/api/v1/webhooks/payment/sepay/checkout?ref=txn_ev6mj8exxkxwe
40854092
type: string
40864093
created_at:
40874094
format: date-time
@@ -10031,6 +10038,8 @@ paths:
1003110038
description: |
1003210039
Creates a ledger leg on the chosen rail and returns it with the gateway redirect in `checkout_url`. Omit `amount` to tender the whole outstanding balance; pass it to split the session across several rails, calling this again per rail until the total is covered.
1003310040
10041+
Tendering a rail that already has an unfinished attempt **resumes** it: the same leg comes back with the same `checkout_url`, and `amount` is ignored. A redirect rail reports nothing when the payer closes its tab, so a second live gateway page would be one the session cannot account for — whichever payment landed second would have no outstanding balance left to settle against.
10042+
1003410043
The leg starts `pending` and only the provider's webhook settles it — this response is not a receipt. Poll the session or the leg for the outcome.
1003510044
parameters:
1003610045
- in: path

‎internal/module/finance/adapter/postgres/session.go‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,14 @@ func (r *Repo) NextTransactionID(ctx context.Context) (int64, error) {
9999
}
100100

101101
const transactionColumns = `id, session_id, status::text, note, error, payment_option,
102-
provider_ref, data, amount, currency, reverses_id, created_at, settled_at, expired_at`
102+
provider_ref, checkout_url, data, amount, currency, reverses_id, created_at,
103+
settled_at, expired_at`
103104

104105
func scanTransaction(row pgx.Row) (domain.Transaction, error) {
105106
var t domain.Transaction
106107
err := row.Scan(&t.ID, &t.SessionID, &t.Status, &t.Note, &t.Error, &t.PaymentOption,
107-
&t.ProviderRef, &t.Data, &t.Amount, &t.Currency, &t.ReversesID, &t.CreatedAt,
108-
&t.SettledAt, &t.ExpiredAt)
108+
&t.ProviderRef, &t.CheckoutURL, &t.Data, &t.Amount, &t.Currency, &t.ReversesID,
109+
&t.CreatedAt, &t.SettledAt, &t.ExpiredAt)
109110
if dbx.IsNoRows(err) {
110111
return domain.Transaction{}, domain.ErrTransactionNotFound
111112
}
@@ -117,15 +118,16 @@ func scanTransaction(row pgx.Row) (domain.Transaction, error) {
117118

118119
func (r *Repo) InsertTransaction(ctx context.Context, t *domain.Transaction) error {
119120
const q = `INSERT INTO transaction
120-
(id, session_id, status, note, error, payment_option, provider_ref, data,
121-
amount, currency, reverses_id, expired_at)
121+
(id, session_id, status, note, error, payment_option, provider_ref,
122+
checkout_url, data, amount, currency, reverses_id, expired_at)
122123
VALUES (@id, @session_id, @status, @note, @error, @payment_option, @provider_ref,
123-
@data, @amount, @currency, @reverses_id, @expired_at)
124+
@checkout_url, @data, @amount, @currency, @reverses_id, @expired_at)
124125
RETURNING created_at`
125126
args := pgx.NamedArgs{
126127
"id": t.ID, "session_id": t.SessionID, "status": t.Status, "note": t.Note,
127128
"error": t.Error, "payment_option": t.PaymentOption, "provider_ref": t.ProviderRef,
128-
"data": dbx.JSONObject(rawJSON(t.Data)), "amount": t.Amount, "currency": t.Currency,
129+
"checkout_url": t.CheckoutURL,
130+
"data": dbx.JSONObject(rawJSON(t.Data)), "amount": t.Amount, "currency": t.Currency,
129131
"reverses_id": t.ReversesID, "expired_at": t.ExpiredAt,
130132
}
131133
if err := r.pool.QueryRow(ctx, q, args).Scan(&t.CreatedAt); err != nil {

‎internal/module/finance/api/api.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ type Session struct {
2525
// Outstanding is the total less what has already settled on a rail: what a further
2626
// payment may still tender. Computed, because a stored copy is a second fact to
2727
// keep in step with every leg.
28-
Outstanding int64 `json:"outstanding"`
28+
Outstanding int64 `json:"outstanding"`
29+
// CheckoutURL is the gateway page a payment attempt on this session is still waiting at,
30+
// so a payer who closed that tab can be sent straight back to it. Empty when there is
31+
// nothing to go back to — no attempt yet, a rail that redirects nowhere, or a session
32+
// past its deadline — and a client then has to choose a rail first.
33+
CheckoutURL string `json:"checkout_url"`
2934
Note string `json:"note"`
3035
// Data is the checkout context whoever opened the session wrote — the draft or offer it came
3136
// from, and the delivery charge included in the total. Read back rather than kept only by the
@@ -59,8 +64,9 @@ type Transaction struct {
5964
// provider gave, the reason a reversal was made.
6065
Note string `json:"note"`
6166
ReversesID *id.ID[id.Transaction] `json:"reverses_id"`
62-
// CheckoutURL is the gateway's redirect, present only while the leg is pending and
63-
// only for a rail that redirects. Not a receipt: the webhook settles the leg.
67+
// CheckoutURL is the gateway page this leg sent the payer to, answered for as long as the
68+
// leg can still be paid — tendering the same rail again hands back this same leg rather
69+
// than opening a second one. Not a receipt: the webhook settles the leg.
6470
CheckoutURL string `json:"checkout_url"`
6571
Error string `json:"error"`
6672
CreatedAt time.Time `json:"created_at"`

‎internal/module/finance/api/openapi/payment-session.yaml‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ paths:
8282
again per rail until the total is covered.
8383
8484
85+
Tendering a rail that already has an unfinished attempt **resumes** it: the same leg
86+
comes back with the same `checkout_url`, and `amount` is ignored. A redirect rail
87+
reports nothing when the payer closes its tab, so a second live gateway page would be
88+
one the session cannot account for — whichever payment landed second would have no
89+
outstanding balance left to settle against.
90+
91+
8592
The leg starts `pending` and only the provider's webhook settles it — this
8693
response is not a receipt. Poll the session or the leg for the outcome.
8794
security: [{ bearerAuth: [] }]
@@ -169,14 +176,22 @@ components:
169176
The total less what has already settled on a rail: what a further payment may
170177
still tender. Computed, because a stored copy would be a second fact to keep in
171178
step with every leg.
179+
checkout_url:
180+
type: string
181+
example: 'https://api.shopnexus.io/api/v1/webhooks/payment/sepay/checkout?ref=txn_ev6mj8exxkxwe'
182+
description: >
183+
The gateway page an unfinished payment attempt is still waiting at — send the payer
184+
straight back to it. Empty when there is nothing to return to: no attempt yet, a rail
185+
that redirects nowhere, or a session past its deadline. A client that finds it empty
186+
has to ask for a rail first, through `POST /payment-sessions/{id}/payments`.
172187
note: { type: string }
173188
created_at: { type: string, format: date-time }
174189
paid_at: { type: string, format: date-time, nullable: true }
175190
expired_at:
176191
type: string
177192
format: date-time
178193
description: A session still unsettled past this point is voided by a job.
179-
required: [id, kind, status, currency, total_amount, outstanding, note, created_at, paid_at, expired_at]
194+
required: [id, kind, status, currency, total_amount, outstanding, checkout_url, note, created_at, paid_at, expired_at]
180195

181196
PaymentSessionPage:
182197
type: object
@@ -208,8 +223,10 @@ components:
208223
description: The leg this one reverses. Set on reversals, null on originals.
209224
checkout_url:
210225
type: string
211-
description: Where to send the payer for this leg. Empty once the rail no
212-
longer needs it, or for rails that have none.
226+
example: 'https://api.shopnexus.io/api/v1/webhooks/payment/sepay/checkout?ref=txn_ev6mj8exxkxwe'
227+
description: Where to send the payer for this leg, answered for as long as the leg
228+
can still be paid. Empty once it has settled or expired, and for rails that
229+
redirect nowhere.
213230
error: { type: string, description: Empty unless the rail reported a failure on this leg. }
214231
note: { type: string }
215232
created_at: { type: string, format: date-time }

‎internal/module/finance/domain/session.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ func (s Session) Settled() bool {
9090
// them. Without this a requester could drive their own cash-out to `success`.
9191
func (s Session) RailPayable() bool { return s.Kind == KindBuyerCheckout }
9292

93+
// Resumable reports whether an unfinished attempt on this session may still be reopened.
94+
// Past the deadline or already settled it may not: the payer would be sent to a live gateway
95+
// page for money the session can no longer account for, and nothing downstream would credit it.
96+
func (s Session) Resumable(now time.Time) bool {
97+
return !s.Settled() && now.Before(s.ExpiredAt)
98+
}
99+
93100
// Charge is the session accepting a payment attempt. Only a pending session is
94101
// payable — a processing one already has a leg in flight, and paying twice is what
95102
// the status is there to prevent.

‎internal/module/finance/domain/transaction.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ type Transaction struct {
2525
// redelivered webhook being booked as a second charge. NULL only while the leg is
2626
// pending and the gateway has not assigned one.
2727
ProviderRef *string
28+
// CheckoutURL is the gateway page this leg sent the payer to. Nil for a direct-debit
29+
// rail, which decides on the spot and has nowhere to send anybody.
30+
CheckoutURL *string
2831
Data []byte
2932
// Amount is signed: positive is the charge, negative is the reversal.
3033
Amount int64 `validate:"required"`
@@ -51,6 +54,19 @@ func NewCharge(id, sessionID int64, option, currency string, amount int64, data
5154
return t, nil
5255
}
5356

57+
// Resumable reports whether the payer can be sent back to this leg's gateway page.
58+
//
59+
// A redirect rail reports nothing when the payer closes its tab, so an abandoned attempt is
60+
// indistinguishable from one still in progress — and it is the same attempt either way. That
61+
// is what makes reopening the right answer and a second leg the wrong one: two live pages on
62+
// one session can both be paid, and the second payment has no outstanding balance to land on.
63+
func (t Transaction) Resumable(now time.Time) bool {
64+
if t.Status != StatusPending || t.CheckoutURL == nil || *t.CheckoutURL == "" {
65+
return false
66+
}
67+
return t.ExpiredAt == nil || now.Before(*t.ExpiredAt)
68+
}
69+
5470
// NewReversal is the refund leg: a negative row pointing at the charge it undoes.
5571
// The original has to have settled — reversing something that never happened is a
5672
// bookkeeping error, not a refund.

‎internal/module/finance/migrations/001_init.sql‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ CREATE TABLE IF NOT EXISTS "transaction" (
104104
-- not assigned one yet.
105105
"provider_ref" TEXT,
106106

107+
-- Where the payer was sent for this leg. Stored, not just returned once, because a
108+
-- redirect rail reports nothing when the payer closes its tab: the leg stays pending
109+
-- and this is the only way back to the page they left. NULL for a rail that has none.
110+
"checkout_url" TEXT,
111+
107112
-- Rail-specific payload: gateway request/response, webhook payload, processor IDs
108113
"data" JSONB NOT NULL,
109114

‎internal/module/finance/service.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,14 +146,15 @@ func (s *Service) paymentOption(ctx context.Context, slug string) (common.Option
146146
// DTO, so this needs no bounds of its own.
147147
func offsetOf(page, limit int) int { return (page - 1) * limit }
148148

149-
func toAPISession(s domain.Session, outstanding int64) financeapi.Session {
149+
func toAPISession(s domain.Session, t tender) financeapi.Session {
150150
return financeapi.Session{
151151
ID: id.Of[id.PaymentSession](s.ID),
152152
Kind: s.Kind,
153153
Status: s.Status,
154154
Currency: s.Currency,
155155
TotalAmount: s.TotalAmount,
156-
Outstanding: outstanding,
156+
Outstanding: t.Outstanding,
157+
CheckoutURL: t.CheckoutURL,
157158
Note: s.Note,
158159
Data: rawOrEmpty(s.Data),
159160
CreatedAt: s.CreatedAt,
@@ -172,7 +173,13 @@ func toAPIWallet(w domain.Wallet) financeapi.Wallet {
172173
}
173174
}
174175

175-
func toAPITransaction(t domain.Transaction, checkoutURL string) financeapi.Transaction {
176+
func toAPITransaction(t domain.Transaction) financeapi.Transaction {
177+
// Only while the leg can still be paid: the field is where to send the payer, and a
178+
// settled or expired leg's page is somewhere they must not be sent.
179+
checkoutURL := ""
180+
if t.Resumable(time.Now()) {
181+
checkoutURL = *t.CheckoutURL
182+
}
176183
out := financeapi.Transaction{
177184
ID: id.Of[id.Transaction](t.ID),
178185
SessionID: id.Of[id.PaymentSession](t.SessionID),

0 commit comments

Comments
 (0)