feat(shop-sales): ledger contracts + docs for consumer PSP sales tracking
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 <noreply@codebuff.com>
This commit is contained in:
1 parent
16655e8dbc
commit
7c2a34cc7a
3 files changed
+107
No files matched your search
@@ -73,6 +73,20 @@ const (
|
|||||||
// amount + per-rate VAT split, which the ledger books as the reversal
|
// amount + per-rate VAT split, which the ledger books as the reversal
|
||||||
// voucher (1510 C / 304x D / 261x D per rate).
|
// voucher (1510 C / 304x D / 261x D per rate).
|
||||||
InvoiceCredited Type = "invoice.credited"
|
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
|
// Analytics (tracking). Replaces the integer enum 0–6 that finder and
|
||||||
|
|||||||
@@ -137,3 +137,51 @@ func DecodeInvoiceCredited(raw []byte) (InvoiceCredited, error) {
|
|||||||
return c, err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user