From 7c2a34cc7ad7fa9d8c7679f84cf5ca738566443f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Mon, 17 Aug 2026 20:01:58 +0200 Subject: [PATCH] feat(shop-sales): ledger contracts + docs for consumer PSP sales tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform/order + platform/event gain the sale.paid and refund.issued wire contracts (per-rate VatBuckets, PSP provider, timestamps) that drive the ledger's SHOP-/REFUND- vouchers (go-cart-actor 2b819bd). docs/status.md records the shop-sales + PSP settlement stack (tickets 17 + 18); the wayfinder ledger tracker gains the two closed tickets and their Decisions entries. Bumps go-cart-actor + backoffice (PSP float stat). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- event/event.go | 14 +++++++++++++ order/order.go | 48 +++++++++++++++++++++++++++++++++++++++++++++ order/order_test.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/event/event.go b/event/event.go index 4165b59..3e28966 100644 --- a/event/event.go +++ b/event/event.go @@ -73,6 +73,20 @@ const ( // amount + per-rate VAT split, which the ledger books as the reversal // voucher (1510 C / 304x D / 261x D per rate). InvoiceCredited Type = "invoice.credited" + // SalePaid is emitted when a consumer (PSP) order is captured — the + // accounting fact for shop sales: amount + per-rate VAT buckets + payment + // method + capture timestamp. Distinct from InvoiceIssued: invoice orders + // book via invoice.issued, so SalePaid must NOT fire for them. Payload is an + // order.SalePaid (platform/order). Consumer: cmd/ledger (aggregates into the + // daily SHOP- voucher). + SalePaid Type = "sale.paid" + // RefundIssued is emitted when a refund is issued against a captured PSP + // order (refund-order / cancel-order flows). Payload is an + // order.RefundIssued (platform/order): the refund amount + pro-rata VAT + // split. The ledger books the reversal voucher (clearing C / 304x D / + // 261x D per rate), idempotent per refund id. Invoice orders reverse via + // credit notes (invoice.credited), so RefundIssued never fires for them. + RefundIssued Type = "refund.issued" // Analytics (tracking). Replaces the integer enum 0–6 that finder and diff --git a/order/order.go b/order/order.go index 1d93de7..c248013 100644 --- a/order/order.go +++ b/order/order.go @@ -137,3 +137,51 @@ func DecodeInvoiceCredited(raw []byte) (InvoiceCredited, error) { return c, err } +// SalePaid is the payload of an event.SalePaid envelope: a consumer (PSP) +// order was captured. It is the accounting fact for shop sales — the ledger +// aggregates captured sales into the daily SHOP- voucher (clearing D / +// 304x C per rate / 261x C per rate). Invoice orders never emit it (they book +// via invoice.issued). +// +// PaymentMethod is the PSP provider name (e.g. "stripe", "klarna"), carried +// from the order's payment record. CapturedAtMs is the capture moment — the +// ledger keys the daily voucher on its UTC date (fakturametoden: capture date +// decides the VAT period). +type SalePaid struct { + OrderID string `json:"order_id"` + PaymentMethod string `json:"payment_method,omitempty"` + AmountIncVat int64 `json:"amount_inc_vat"` + VatBuckets []VatBucket `json:"vat_buckets,omitempty"` + CapturedAtMs int64 `json:"captured_at_ms"` +} + +func (s SalePaid) Encode() ([]byte, error) { return json.Marshal(s) } + +func DecodeSalePaid(raw []byte) (SalePaid, error) { + var s SalePaid + err := json.Unmarshal(raw, &s) + return s, err +} + +// RefundIssued is the payload of an event.RefundIssued envelope: a refund was +// issued against a captured PSP order. The amount and per-rate VAT split are +// pro-rated from the order's captured sale (line-level return mapping is out +// of scope). The ledger books the reversal voucher (clearing C / 304x D / +// 261x D per rate), idempotent per RefundID (the provider refund reference, +// unique per refund). Invoice orders reverse via invoice.credited instead. +type RefundIssued struct { + OrderID string `json:"order_id"` + RefundID string `json:"refund_id"` + AmountIncVat int64 `json:"amount_inc_vat"` + VatBuckets []VatBucket `json:"vat_buckets,omitempty"` + RefundedAtMs int64 `json:"refunded_at_ms"` +} + +func (r RefundIssued) Encode() ([]byte, error) { return json.Marshal(r) } + +func DecodeRefundIssued(raw []byte) (RefundIssued, error) { + var r RefundIssued + err := json.Unmarshal(raw, &r) + return r, err +} + diff --git a/order/order_test.go b/order/order_test.go index b32d46b..ad1427c 100644 --- a/order/order_test.go +++ b/order/order_test.go @@ -74,3 +74,48 @@ func TestInvoicePaidRoundTrip(t *testing.T) { } } +func TestSalePaidRoundTrip(t *testing.T) { + in := SalePaid{ + OrderID: "ord-1", + PaymentMethod: "stripe", + AmountIncVat: 14995, + VatBuckets: []VatBucket{{RateBp: 2500, ExVat: 11996, Vat: 2999}}, + CapturedAtMs: 1234567890, + } + raw, err := in.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := DecodeSalePaid(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.OrderID != in.OrderID || out.PaymentMethod != in.PaymentMethod || out.AmountIncVat != in.AmountIncVat || out.CapturedAtMs != in.CapturedAtMs { + t.Fatalf("round-trip mismatch: got %+v, want %+v", out, in) + } + if len(out.VatBuckets) != 1 || out.VatBuckets[0].RateBp != 2500 || out.VatBuckets[0].Vat != 2999 { + t.Fatalf("bucket mismatch: %+v", out.VatBuckets) + } +} + +func TestRefundIssuedRoundTrip(t *testing.T) { + in := RefundIssued{ + OrderID: "ord-1", + RefundID: "re_123", + AmountIncVat: 7498, + VatBuckets: []VatBucket{{RateBp: 2500, ExVat: 5998, Vat: 1500}}, + RefundedAtMs: 1234567990, + } + raw, err := in.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := DecodeRefundIssued(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.OrderID != in.OrderID || out.RefundID != in.RefundID || out.AmountIncVat != in.AmountIncVat { + t.Fatalf("round-trip mismatch: got %+v, want %+v", out, in) + } +} +