mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
294 lines
13 KiB
Go
294 lines
13 KiB
Go
package routable
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/observe/log"
|
|
"github.com/hanzo-fi/payments/ee/plugins/routable/client"
|
|
"github.com/hanzo-fi/payments/pkg/domain/plugins"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"go.uber.org/mock/gomock"
|
|
)
|
|
|
|
var _ = Describe("Routable fetchNextPayments", func() {
|
|
var (
|
|
ctrl *gomock.Controller
|
|
mock *client.MockClient
|
|
plg *Plugin
|
|
logger = logging.NewDefaultLogger(GinkgoWriter, true, false, false)
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctrl = gomock.NewController(GinkgoT())
|
|
mock = client.NewMockClient(ctrl)
|
|
plg = &Plugin{Plugin: plugins.NewBasePlugin(), name: "routable", logger: logger, client: mock}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
ctrl.Finish()
|
|
})
|
|
|
|
It("emits payables as PAYOUT and switches phase to receivables when exhausted", func(ctx SpecContext) {
|
|
now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
mock.EXPECT().ListPayables(gomock.Any(), 1, 50, time.Time{}).Return(&client.ListPayablesResponse{
|
|
Results: []client.Payable{{
|
|
ID: "pa_1",
|
|
Status: "completed",
|
|
Amount: "10.00",
|
|
CurrencyCode: "USD",
|
|
DeliveryMethod: "ach_standard",
|
|
PayToCompany: &client.PayableCompany{ID: "co_1"},
|
|
WithdrawFromAccount: &client.PayableAccount{ID: "acc_1"},
|
|
StatusChangedAt: &now,
|
|
CreatedAt: now,
|
|
}},
|
|
}, nil)
|
|
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50})
|
|
Expect(err).To(BeNil())
|
|
Expect(resp.Payments).To(HaveLen(1))
|
|
Expect(resp.Payments[0].Reference).To(Equal("pa_1"))
|
|
Expect(resp.Payments[0].Type).To(Equal(models.PAYMENT_TYPE_PAYOUT))
|
|
Expect(resp.Payments[0].Status).To(Equal(models.PAYMENT_STATUS_SUCCEEDED))
|
|
Expect(resp.Payments[0].Scheme).To(Equal(models.PAYMENT_SCHEME_ACH))
|
|
Expect(*resp.Payments[0].SourceAccountReference).To(Equal("acc_1"))
|
|
Expect(*resp.Payments[0].DestinationAccountReference).To(Equal("co_1"))
|
|
Expect(resp.HasMore).To(BeTrue()) // moves on to receivables phase
|
|
|
|
var state paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &state)).To(Succeed())
|
|
Expect(state.Phase).To(Equal(phaseReceivables))
|
|
// CycleLowerBound stays put for receivables; only CycleMaxSeen advances.
|
|
Expect(state.CycleLowerBound.IsZero()).To(BeTrue())
|
|
Expect(state.CycleMaxSeen.Equal(now)).To(BeTrue())
|
|
})
|
|
|
|
It("commits CycleMaxSeen as next CycleLowerBound only after receivables exhausts", func(ctx SpecContext) {
|
|
now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, time.Time{}).Return(&client.ListReceivablesResponse{
|
|
Results: []client.Receivable{{
|
|
ID: "re_1",
|
|
Status: "pending",
|
|
Amount: "5.00",
|
|
CurrencyCode: "USD",
|
|
DeliveryMethod: "ach_standard",
|
|
PayFromCompany: &client.ReceivableCompany{ID: "co_42"},
|
|
DepositToAccount: &client.ReceivableAccount{ID: "acc_99"},
|
|
CreatedAt: now,
|
|
}},
|
|
}, nil)
|
|
|
|
// Mid-cycle state: receivables phase, payables already advanced
|
|
// CycleMaxSeen but CycleLowerBound is still zero from the very first
|
|
// run. Receivables exhausts, so we expect the cycle to commit.
|
|
incoming, _ := json.Marshal(paymentsState{
|
|
Phase: phaseReceivables,
|
|
Page: 1,
|
|
CycleMaxSeen: now,
|
|
})
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: incoming})
|
|
Expect(err).To(BeNil())
|
|
Expect(resp.Payments).To(HaveLen(1))
|
|
Expect(resp.Payments[0].Type).To(Equal(models.PAYMENT_TYPE_PAYIN))
|
|
Expect(resp.HasMore).To(BeFalse())
|
|
|
|
var state paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &state)).To(Succeed())
|
|
Expect(state.Phase).To(Equal(phasePayables))
|
|
Expect(state.Page).To(Equal(1))
|
|
Expect(state.CycleLowerBound.Equal(now)).To(BeTrue(), "CycleMaxSeen must be promoted to CycleLowerBound on cycle commit")
|
|
Expect(state.CycleMaxSeen.IsZero()).To(BeTrue(), "CycleMaxSeen must reset for the next cycle")
|
|
})
|
|
|
|
It("holds CycleLowerBound immutable across multi-page payables", func(ctx SpecContext) {
|
|
floor := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
later := time.Date(2025, 6, 2, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Page 1 advances CycleMaxSeen to `later` and reports HasMore.
|
|
mock.EXPECT().ListPayables(gomock.Any(), 1, 50, floor).Return(&client.ListPayablesResponse{
|
|
Results: []client.Payable{{
|
|
ID: "pa_p1", Status: "pending", Amount: "1.00", CurrencyCode: "USD",
|
|
StatusChangedAt: &later, CreatedAt: later,
|
|
}},
|
|
Links: client.Links{Next: "/v1/payables?page=2"},
|
|
}, nil)
|
|
|
|
// Page 2 MUST be requested with the SAME floor as page 1 — never
|
|
// the page-1 max. This is the regression CodeRabbit caught.
|
|
mock.EXPECT().ListPayables(gomock.Any(), 2, 50, floor).Return(&client.ListPayablesResponse{}, nil)
|
|
|
|
state, _ := json.Marshal(paymentsState{Page: 1, CycleLowerBound: floor})
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: state})
|
|
Expect(err).To(BeNil())
|
|
Expect(resp.HasMore).To(BeTrue())
|
|
|
|
var nextState paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &nextState)).To(Succeed())
|
|
Expect(nextState.Page).To(Equal(2))
|
|
Expect(nextState.CycleLowerBound.Equal(floor)).To(BeTrue())
|
|
Expect(nextState.CycleMaxSeen.Equal(later)).To(BeTrue())
|
|
|
|
// Issue page 2 with the state we just produced.
|
|
_, err = plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: resp.NewState})
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("uses the same floor for receivables as for payables in the same cycle", func(ctx SpecContext) {
|
|
floor := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
later := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Receivables phase must call ListReceivables with the floor, not
|
|
// with the CycleMaxSeen produced by the payables phase. Otherwise
|
|
// receivables that changed between `floor` and `later` are skipped.
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, floor).Return(&client.ListReceivablesResponse{}, nil)
|
|
|
|
state, _ := json.Marshal(paymentsState{
|
|
Phase: phaseReceivables,
|
|
Page: 1,
|
|
CycleLowerBound: floor,
|
|
CycleMaxSeen: later,
|
|
})
|
|
_, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: state})
|
|
Expect(err).To(BeNil())
|
|
})
|
|
|
|
It("preserves CycleLowerBound when both phases of a cycle return no rows", func(ctx SpecContext) {
|
|
// Regression for the empty-cycle bug: at high write throughput a
|
|
// 200k/wk connector may legitimately see a cycle with zero status
|
|
// transitions for both payables and receivables. Promoting
|
|
// CycleMaxSeen=0 to CycleLowerBound would regress the floor to
|
|
// epoch and trigger a full historical refetch on the next cycle.
|
|
previousFloor := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Receivables phase, both pages empty: this is the cycle-end branch
|
|
// we're guarding.
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, previousFloor).Return(&client.ListReceivablesResponse{}, nil)
|
|
|
|
state, _ := json.Marshal(paymentsState{
|
|
Phase: phaseReceivables,
|
|
Page: 1,
|
|
CycleLowerBound: previousFloor,
|
|
// CycleMaxSeen left at zero — payables phase saw nothing either.
|
|
})
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: state})
|
|
Expect(err).To(BeNil())
|
|
Expect(resp.HasMore).To(BeFalse())
|
|
|
|
var nextState paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &nextState)).To(Succeed())
|
|
Expect(nextState.Phase).To(Equal(phasePayables))
|
|
Expect(nextState.CycleLowerBound.Equal(previousFloor)).To(BeTrue(),
|
|
"empty cycle must NOT regress CycleLowerBound to zero")
|
|
Expect(nextState.CycleMaxSeen.IsZero()).To(BeTrue())
|
|
})
|
|
|
|
It("preserves CycleLowerBound when only payables saw rows but receivables didn't", func(ctx SpecContext) {
|
|
previousFloor := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
payableSeen := time.Date(2025, 6, 3, 0, 0, 0, 0, time.UTC)
|
|
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, previousFloor).Return(&client.ListReceivablesResponse{}, nil)
|
|
|
|
// Payables phase already advanced CycleMaxSeen to payableSeen.
|
|
state, _ := json.Marshal(paymentsState{
|
|
Phase: phaseReceivables,
|
|
Page: 1,
|
|
CycleLowerBound: previousFloor,
|
|
CycleMaxSeen: payableSeen,
|
|
})
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: state})
|
|
Expect(err).To(BeNil())
|
|
|
|
var nextState paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &nextState)).To(Succeed())
|
|
Expect(nextState.CycleLowerBound.Equal(payableSeen)).To(BeTrue(),
|
|
"non-zero CycleMaxSeen from payables phase must still promote at cycle end")
|
|
})
|
|
|
|
// Resumable: paymentsState round-trips through JSON cleanly so that a
|
|
// worker crash mid-cycle resumes at the correct page with the same
|
|
// CycleLowerBound. At 200k tx/wk the cycle has many pages; losing
|
|
// state would cause both duplicates AND skips on resume.
|
|
It("survives JSON round-trip mid-cycle without losing the floor", func(ctx SpecContext) {
|
|
floor := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
later := time.Date(2025, 6, 4, 0, 0, 0, 0, time.UTC)
|
|
|
|
mid := paymentsState{Phase: phaseReceivables, Page: 7, CycleLowerBound: floor, CycleMaxSeen: later}
|
|
raw, err := json.Marshal(mid)
|
|
Expect(err).To(BeNil())
|
|
|
|
decoded, err := decodePaymentsState(raw)
|
|
Expect(err).To(BeNil())
|
|
Expect(decoded.Phase).To(Equal(phaseReceivables))
|
|
Expect(decoded.Page).To(Equal(7))
|
|
Expect(decoded.CycleLowerBound.Equal(floor)).To(BeTrue())
|
|
Expect(decoded.CycleMaxSeen.Equal(later)).To(BeTrue())
|
|
})
|
|
|
|
// Tiebreaker / lossless: rows whose status_changed_at equals the
|
|
// cycle floor are re-emitted at every cycle boundary because
|
|
// Routable's gte filter is inclusive. We rely on the engine's
|
|
// PSPPayment.Reference dedup; the cost is at-most-N replays of the
|
|
// boundary rows, never lost rows. This test pins the contract by
|
|
// driving two full cycles and asserting the boundary row keeps the
|
|
// same Reference each time, so engine dedup catches the replay.
|
|
It("re-emits boundary rows with the same Reference across cycles for engine dedup", func(ctx SpecContext) {
|
|
floor := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
boundaryRow := func() client.Receivable {
|
|
return client.Receivable{
|
|
ID: "re_boundary",
|
|
Status: "completed",
|
|
Amount: "1.00",
|
|
CurrencyCode: "USD",
|
|
DeliveryMethod: "ach_standard",
|
|
StatusChangedAt: &floor,
|
|
CreatedAt: floor,
|
|
}
|
|
}
|
|
|
|
// Cycle 1 — receivables phase, ends with boundary row.
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, floor).Return(&client.ListReceivablesResponse{
|
|
Results: []client.Receivable{boundaryRow()},
|
|
}, nil)
|
|
|
|
state, _ := json.Marshal(paymentsState{Phase: phaseReceivables, Page: 1, CycleLowerBound: floor})
|
|
c1, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: state})
|
|
Expect(err).To(BeNil())
|
|
Expect(c1.Payments).To(HaveLen(1))
|
|
Expect(c1.Payments[0].Reference).To(Equal("re_boundary"))
|
|
Expect(c1.HasMore).To(BeFalse(), "cycle ended")
|
|
|
|
// Cycle 2 starts in payables phase (empty in this scenario).
|
|
mock.EXPECT().ListPayables(gomock.Any(), 1, 50, floor).Return(&client.ListPayablesResponse{}, nil)
|
|
c2payables, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: c1.NewState})
|
|
Expect(err).To(BeNil())
|
|
Expect(c2payables.HasMore).To(BeTrue(), "transitions to receivables phase")
|
|
|
|
// Cycle 2 receivables — same boundary row reappears (gte-inclusive).
|
|
mock.EXPECT().ListReceivables(gomock.Any(), 1, 50, floor).Return(&client.ListReceivablesResponse{
|
|
Results: []client.Receivable{boundaryRow()},
|
|
}, nil)
|
|
c2recv, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: c2payables.NewState})
|
|
Expect(err).To(BeNil())
|
|
Expect(c2recv.Payments).To(HaveLen(1))
|
|
Expect(c2recv.Payments[0].Reference).To(Equal("re_boundary"),
|
|
"engine dedup keys on PSPPayment.Reference; boundary row must keep its identity across cycles")
|
|
})
|
|
|
|
It("migrates legacy LastSeenAt state to CycleLowerBound", func(ctx SpecContext) {
|
|
legacy := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
|
mock.EXPECT().ListPayables(gomock.Any(), 1, 50, legacy).Return(&client.ListPayablesResponse{}, nil)
|
|
|
|
legacyState := json.RawMessage(`{"phase":"","page":1,"lastSeenAt":"2024-12-31T00:00:00Z"}`)
|
|
resp, err := plg.fetchNextPayments(ctx, models.FetchNextPaymentsRequest{PageSize: 50, State: legacyState})
|
|
Expect(err).To(BeNil())
|
|
|
|
var migrated paymentsState
|
|
Expect(json.Unmarshal(resp.NewState, &migrated)).To(Succeed())
|
|
Expect(migrated.CycleLowerBound.Equal(legacy)).To(BeTrue())
|
|
Expect(migrated.LastSeenAt.IsZero()).To(BeTrue(), "deprecated LastSeenAt must be cleared after migration")
|
|
})
|
|
})
|