package sales

import (
	"bytes"
	"context"
	"crypto/sha512"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"path"
	"regexp"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/finance"
	"github.com/go-chi/chi/v5"
	"github.com/shopspring/decimal"
)

// midtransSign computes the notification signature exactly like Midtrans does.
func midtransSign(orderID, statusCode, grossAmount, serverKey string) string {
	sum := sha512.Sum512([]byte(orderID + statusCode + grossAmount + serverKey))
	return hex.EncodeToString(sum[:])
}

// notifOrderWithInvoice checks out an order, books the unpaid invoice, and
// issues a Snap token so the invoice carries a snap_order_ref.
func notifOrderWithRef(t *testing.T, s *Service) (*SalesOrder, *Invoice, string) {
	t.Helper()
	o, invID := snapOrderWithInvoice(t, s)
	snapSvcWithStub(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	fs := s.store.(*fakeStore)
	inv, ok := fs.invoices[invID]
	if !ok || inv.SnapOrderRef == nil {
		t.Fatalf("invoice %d has no snap_order_ref", invID)
	}
	return o, inv, *inv.SnapOrderRef
}

func TestMidtransNotification_SettlesInvoiceOnce(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	gross := inv.RemainingAmount.StringFixed(2)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionID: "tx-1", TransactionStatus: "settlement",
		StatusCode: "200", GrossAmount: gross, PaymentType: "gopay",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
	out, err := s.HandleMidtransNotification(context.Background(), n)
	if err != nil {
		t.Fatal(err)
	}
	if out != finance.MidtransOutcomeSettled {
		t.Fatalf("outcome = %q, want settled", out)
	}
	fs := s.store.(*fakeStore)
	after := fs.invoices[inv.ID]
	if after.Status != "paid" || !after.RemainingAmount.IsZero() {
		t.Errorf("invoice status = %s remaining = %s, want paid/0", after.Status, after.RemainingAmount)
	}
	got, err := s.Get(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	// A settled webhook records the money but does not accept the order: it
	// must still sit in pending_approval until a human runs Confirm.
	if got.Status != "pending_approval" {
		t.Errorf("order status = %s, want pending_approval (payment must not auto-accept)", got.Status)
	}
	if !got.PaidAmount.Equal(got.TotalAmount) || !got.RemainingAmount.IsZero() {
		t.Errorf("paid = %s remaining = %s, want fully settled", got.PaidAmount, got.RemainingAmount)
	}
	// And the human acceptance still works afterwards, recording who accepted.
	accepted, err := s.Confirm(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("Confirm on a settled-but-unaccepted order: %v", err)
	}
	if accepted.Status != "confirmed" {
		t.Errorf("status after Confirm = %s, want confirmed", accepted.Status)
	}
	// Replay: duplicate webhook is idempotent.
	out2, err := s.HandleMidtransNotification(context.Background(), n)
	if err != nil || out2 != finance.MidtransOutcomeAlreadySettled {
		t.Errorf("replay outcome = %q err = %v, want already-settled", out2, err)
	}
	// Monotonic: a later cancel/deny notification must not un-pay.
	ncancel := n
	ncancel.TransactionStatus = "cancel"
	ncancel.SignatureKey = midtransSign(ref, "200", gross, "test-server-key")
	out3, err := s.HandleMidtransNotification(context.Background(), ncancel)
	if err != nil || out3 != finance.MidtransOutcomeFailedRecorded {
		t.Errorf("late cancel outcome = %q err = %v", out3, err)
	}
	if fs.invoices[inv.ID].Status != "paid" {
		t.Error("late cancel notification un-paid a settled invoice")
	}
}

func TestMidtransNotification_CaptureChallengeStaysOpen(t *testing.T) {
	s := svc()
	_, inv, ref := notifOrderWithRef(t, s)
	gross := inv.RemainingAmount.StringFixed(2)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionStatus: "capture", FraudStatus: "challenge",
		StatusCode: "200", GrossAmount: gross, PaymentType: "credit_card",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
	out, err := s.HandleMidtransNotification(context.Background(), n)
	if err != nil {
		t.Fatal(err)
	}
	if out != finance.MidtransOutcomeIgnored {
		t.Errorf("outcome = %q, want ignored (challenge must not settle)", out)
	}
	if s.store.(*fakeStore).invoices[inv.ID].Status == "paid" {
		t.Error("challenged capture settled the invoice")
	}
}

func TestMidtransNotification_BadSignatureRejected(t *testing.T) {
	s := svc()
	_, _, ref := notifOrderWithRef(t, s)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionStatus: "settlement", StatusCode: "200",
		GrossAmount: "200.00", SignatureKey: "deadbeef",
	}
	if _, err := s.HandleMidtransNotification(context.Background(), n); !errors.Is(err, finance.ErrMidtransBadSignature) {
		t.Errorf("err = %v, want ErrMidtransBadSignature", err)
	}
}

func TestMidtransNotification_AmountMismatchRejected(t *testing.T) {
	s := svc()
	_, _, ref := notifOrderWithRef(t, s)
	// Correct signature over a WRONG amount: the amount reconciliation (not
	// the signature) is what catches a tampered/mis-categorized total.
	gross := "1.00"
	n := finance.MidtransNotification{
		OrderID: ref, TransactionStatus: "settlement", StatusCode: "200",
		GrossAmount: gross, SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
	if _, err := s.HandleMidtransNotification(context.Background(), n); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
		t.Errorf("err = %v, want ErrMidtransAmountMismatch", err)
	}
}

func TestMidtransNotification_UnknownOrderRejected(t *testing.T) {
	s := svc()
	snapSvcWithStub(t, s)
	gross := "200.00"
	n := finance.MidtransNotification{
		OrderID: "KM-INV-NOPE-999", TransactionStatus: "settlement", StatusCode: "200",
		GrossAmount: gross, SignatureKey: midtransSign("KM-INV-NOPE-999", "200", gross, "test-server-key"),
	}
	if _, err := s.HandleMidtransNotification(context.Background(), n); !errors.Is(err, finance.ErrMidtransUnknownOrder) {
		t.Errorf("err = %v, want ErrMidtransUnknownOrder", err)
	}
}

func TestMidtransNotification_GatewayDisabled(t *testing.T) {
	s := svc() // no WithMidtrans — server key empty
	if _, err := s.HandleMidtransNotification(context.Background(), finance.MidtransNotification{}); !errors.Is(err, ErrGatewayDisabled) {
		t.Errorf("err = %v, want ErrGatewayDisabled", err)
	}
}

func TestMidtransNotification_SupersededRetryRefStillSettles(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	// Retry SnapToken: snap_order_ref is overwritten with the timestamped ref.
	st := snapSvcWithStub(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	fs := s.store.(*fakeStore)
	newRef := *fs.invoices[inv.ID].SnapOrderRef
	if newRef == ref {
		t.Fatal("retry should have replaced the snap ref")
	}
	// The buyer paid on the FIRST token; its webhook arrives with the OLD ref.
	gross := inv.RemainingAmount.StringFixed(2)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionStatus: "settlement", StatusCode: "200",
		GrossAmount: gross, PaymentType: "qris",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
	out, err := s.HandleMidtransNotification(context.Background(), n)
	if err != nil {
		t.Fatalf("old-ref webhook rejected: %v", err)
	}
	if out != finance.MidtransOutcomeSettled {
		t.Fatalf("outcome = %q, want settled", out)
	}
	if fs.invoices[inv.ID].Status != "paid" {
		t.Error("invoice not settled from the superseded ref")
	}
	_ = st
}

// statusStubRT serves a canned Core API /v2/{order_id}/status response.
type statusStubRT struct {
	// mu guards calls, refs, and fixture reads when one stub is shared by
	// concurrent sweeps.
	mu     sync.Mutex
	status string
	fraud  string
	calls  int
	// gross overrides the reported gross_amount. Empty keeps the 200.00 the
	// whole-amount fixtures expect.
	gross string
	// byRef answers per order_id, so a fixture can say "the newest attempt was
	// abandoned, the canonical one was paid". Refs with no entry fall back to
	// the flat fields above, so the single-ref fixtures are unaffected.
	byRef map[string]statusReply
	// refs is the order_id of every status call, in call order.
	refs []string
}

// statusReply is one per-ref answer on statusStubRT. Exactly one of
// notFound / transportErr / a 200 body is served.
type statusReply struct {
	status string
	fraud  string
	gross  string
	// notFound serves the provider's 404 for an order_id Midtrans never saw.
	notFound bool
	// transportErr fails the call below HTTP: a dead gateway, not an answer.
	transportErr error
}

func (s *statusStubRT) RoundTrip(r *http.Request) (*http.Response, error) {
	s.mu.Lock()
	s.calls++
	ref := refFromStatusURL(r)
	s.refs = append(s.refs, ref)
	rep, ok := s.byRef[ref]
	status, fraud, gross := s.status, s.fraud, s.gross
	s.mu.Unlock()
	if ok {
		if rep.transportErr != nil {
			return nil, rep.transportErr
		}
		if rep.notFound {
			return jsonResponse(http.StatusNotFound, `{"status_code":"404","status_message":"transaction not found"}`), nil
		}
		return s.jsonReply(rep.status, rep.fraud, rep.gross), nil
	}
	return s.jsonReply(status, fraud, gross), nil
}

func (s *statusStubRT) jsonReply(status, fraud, gross string) *http.Response {
	if gross == "" {
		gross = "200.00"
	}
	body, _ := json.Marshal(map[string]any{
		"status_code": "200", "transaction_status": status, "fraud_status": fraud,
		"payment_type": "gopay", "transaction_id": "tx-9", "gross_amount": gross,
	})
	return jsonResponse(http.StatusOK, string(body))
}

func jsonResponse(code int, body string) *http.Response {
	return &http.Response{
		StatusCode: code,
		Body:       io.NopCloser(strings.NewReader(body)),
		Header:     make(http.Header),
	}
}

// refFromStatusURL extracts the order_id from .../v2/{order_id}/status.
func refFromStatusURL(r *http.Request) string {
	p := strings.TrimSuffix(r.URL.Path, "/status")
	return path.Base(p)
}

func TestSnapPaymentStatus_RecoversMissedWebhook(t *testing.T) {
	s := svc()
	o, _, ref := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	st := &statusStubRT{status: "settlement"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", SnapBaseURL: "http://example.test/snap", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "settled" || out.OrderStatus != "invoiced" {
		t.Errorf("result = %+v, want settled/invoiced", out)
	}
	for _, inv := range fs.invoices {
		if inv.SnapOrderRef != nil && *inv.SnapOrderRef == ref && inv.Status != "paid" {
			t.Error("recovery poll did not settle the invoice")
		}
	}
	if st.calls != 1 {
		t.Errorf("status endpoint calls = %d, want 1", st.calls)
	}
}

func TestSnapPaymentStatus_NotAttemptedAndPending(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	// Reset ref to empty: token never issued.
	for _, inv := range fs.invoices {
		if inv.SalesOrderID == o.ID {
			inv.SnapOrderRef = nil
		}
	}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", SnapBaseURL: "http://example.test/snap", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: &statusStubRT{status: "pending"}})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "not_attempted" {
		t.Errorf("payment_status = %q, want not_attempted (no token yet)", out.PaymentStatus)
	}
	// Restore a ref; pending status maps to ignored, invoice stays open.
	ref := "KM-INV-T-1-" + itoa2(o.ID)
	for _, inv := range fs.invoices {
		if inv.SalesOrderID == o.ID {
			inv.SnapOrderRef = &ref
		}
	}
	out2, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	// "pending", not finance.MidtransActionIgnored. The reconciler vocabulary
	// is reconciler-shaped: every non-terminal provider state collapses to
	// "ignored" because there is nothing to settle. That reached the POS as a
	// payment status and read as a dead transaction, when the only question a
	// till has is keep-waiting-or-give-up. tillPaymentStatus translates.
	if out2.PaymentStatus != "pending" {
		t.Errorf("payment_status = %q, want pending", out2.PaymentStatus)
	}
}

func TestSnapPaymentStatus_CrossUserNotFound(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: &statusStubRT{status: "settlement"}})
	// User 7 has no linked customer in the fake — ownership check rejects.
	if _, err := s.SnapPaymentStatus(context.Background(), o.ID, 7); !errors.Is(err, ErrNotFound) {
		t.Errorf("err = %v, want ErrNotFound", err)
	}
}

func TestSnapPaymentStatus_AlreadySettledShortCircuits(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	fs := s.store.(*fakeStore)
	if _, err := fs.MarkInvoicePaid(context.Background(), nil, invID, decimal.NewFromInt(200)); err != nil {
		t.Fatal(err)
	}
	st := &statusStubRT{status: "settlement"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "settled" {
		t.Errorf("payment_status = %q, want settled (no open invoice)", out.PaymentStatus)
	}
	if st.calls != 0 {
		t.Errorf("status calls = %d, want 0 (settled invoices short-circuit)", st.calls)
	}
}

// --- fractional-amount settlement (rounded gateway amount) --------------------

// fracOrderWithRef books an e-commerce order whose invoice total carries a
// fractional part (product 3 at 160.25 x 2 = 320.50) and issues a Snap token.
// Same shape as notifOrderWithRef, different money: Midtrans is told a ROUNDED
// 321 for a 320.50 invoice and echoes that integer back on the webhook and on
// the Core API status call.
func fracOrderWithRef(t *testing.T, s *Service) (*SalesOrder, *Invoice, string, *snapStubRT) {
	t.Helper()
	prev, hadPrev := fakePrices[3]
	fakePrices[3] = "160.25"
	t.Cleanup(func() {
		if hadPrev {
			fakePrices[3] = prev
			return
		}
		delete(fakePrices, 3)
	})
	fs := s.store.(*fakeStore)
	fs.stocks["3:1"] = &Stock{ID: 3, ProductID: 3, WarehouseID: 1, Quantity: decimal.NewFromInt(10)}
	o, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 3, Quantity: decimal.NewFromInt(2)}},
	}, 42, "Buyer", "buyer@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	if want := decimal.RequireFromString("320.50"); !o.TotalAmount.Equal(want) {
		t.Fatalf("order total = %s, want %s (fractional fixture)", o.TotalAmount, want)
	}
	invID, err := fs.CreateInvoice(context.Background(), nil, createInvoiceParams(&Invoice{
		InvoiceNumber: "INV-T-FRAC", SalesOrderID: o.ID, CustomerID: o.CustomerID, Status: "unpaid",
		InvoiceDate: "2026-09-20", DueDate: "2026-09-20",
		TotalAmount: o.TotalAmount, RemainingAmount: o.TotalAmount,
	}))
	if err != nil {
		t.Fatal(err)
	}
	st := snapSvcWithStub(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	inv, ok := fs.invoices[invID]
	if !ok || inv.SnapOrderRef == nil {
		t.Fatalf("invoice %d has no snap_order_ref", invID)
	}
	return o, inv, *inv.SnapOrderRef, st
}

// sentGross reads the gross_amount the Snap request actually put on the wire.
func sentGross(t *testing.T, st *snapStubRT) int64 {
	t.Helper()
	var payload struct {
		TransactionDetails struct {
			GrossAmount int64 `json:"gross_amount"`
		} `json:"transaction_details"`
	}
	if err := json.Unmarshal(st.lastBody, &payload); err != nil {
		t.Fatalf("snap request body = %s: %v", st.lastBody, err)
	}
	return payload.TransactionDetails.GrossAmount
}

// payAmountFor returns the amount recorded on the single payment row booked
// against the invoice — the ledger must keep the TRUE invoice figure.
func payAmountFor(t *testing.T, fs *fakeStore, invoiceID int64) decimal.Decimal {
	t.Helper()
	for _, p := range fs.payments {
		if p.PayableType == InvoiceModel && p.PayableID == invoiceID {
			return p.Amount
		}
	}
	t.Fatalf("no payment row for invoice %d", invoiceID)
	return decimal.Zero
}

func notifFor(ref, gross string) finance.MidtransNotification {
	return finance.MidtransNotification{
		OrderID: ref, TransactionID: "tx-frac", TransactionStatus: "settlement",
		StatusCode: "200", GrossAmount: gross, PaymentType: "gopay",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
}

// statusSvc wires the recovery poll onto a service whose invoice already
// carries a snap ref.
func statusSvc(t *testing.T, s *Service, reportedGross string) *statusStubRT {
	t.Helper()
	st := &statusStubRT{status: "settlement", gross: reportedGross}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	return st
}

// The regression: a 320.50 invoice is charged 321 (Midtrans takes whole rupiah
// only) and the provider echoes 321. The webhook must settle it — comparing 321
// against the raw 320.50 rejected every captured payment for a fractional
// invoice with 409, and the money was never recorded.
func TestMidtransNotification_SettlesFractionalInvoiceAtRoundedGross(t *testing.T) {
	s := svc()
	_, inv, ref, snap := fracOrderWithRef(t, s)
	if got := sentGross(t, snap); got != 321 {
		t.Fatalf("sent gross_amount = %d, want 321 (320.50 rounded for the gateway)", got)
	}
	out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "321.00"))
	if err != nil {
		t.Fatalf("fractional webhook rejected: %v", err)
	}
	if out != finance.MidtransOutcomeSettled {
		t.Fatalf("outcome = %q, want settled", out)
	}
	fs := s.store.(*fakeStore)
	after := fs.invoices[inv.ID]
	if after.Status != "paid" || !after.RemainingAmount.IsZero() {
		t.Errorf("invoice status = %s remaining = %s, want paid/0", after.Status, after.RemainingAmount)
	}
	// The ledger keeps the invoice's own figure, not the gateway's rounded one.
	if got := payAmountFor(t, fs, inv.ID); !got.Equal(decimal.RequireFromString("320.50")) {
		t.Errorf("payment amount = %s, want 320.50 (true invoice figure)", got)
	}
}

// The same regression on the missed-webhook recovery path: the safety net
// cannot cover a fractional invoice either if it reconciles the raw decimal.
func TestSnapPaymentStatus_RecoversFractionalInvoiceAtRoundedGross(t *testing.T) {
	s := svc()
	o, inv, _, _ := fracOrderWithRef(t, s)
	st := statusSvc(t, s, "321.00")
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("fractional recovery poll rejected: %v", err)
	}
	if out.PaymentStatus != "settled" || out.OrderStatus != "invoiced" {
		t.Errorf("result = %+v, want settled/invoiced", out)
	}
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid (recovery poll must settle)", got)
	}
	if got := payAmountFor(t, fs, inv.ID); !got.Equal(decimal.RequireFromString("320.50")) {
		t.Errorf("payment amount = %s, want 320.50 (true invoice figure)", got)
	}
	if st.calls != 1 {
		t.Errorf("status calls = %d, want 1", st.calls)
	}
}

// A whole-rupiah invoice settles exactly as it always did, in both paths.
func TestMidtransSettle_WholeAmountUnchangedInBothPaths(t *testing.T) {
	s := svc()
	_, inv, ref := notifOrderWithRef(t, s)
	out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "200.00"))
	if err != nil || out != finance.MidtransOutcomeSettled {
		t.Fatalf("whole-amount webhook outcome = %q err = %v, want settled", out, err)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
	if got := payAmountFor(t, s.store.(*fakeStore), inv.ID); !got.Equal(decimal.NewFromInt(200)) {
		t.Errorf("payment amount = %s, want 200", got)
	}

	s2 := svc()
	o2, inv2, _ := notifOrderWithRef(t, s2)
	statusSvc(t, s2, "200.00")
	out2, err := s2.SnapPaymentStatus(context.Background(), o2.ID, 42)
	if err != nil {
		t.Fatalf("whole-amount recovery poll: %v", err)
	}
	if out2.PaymentStatus != "settled" || out2.OrderStatus != "invoiced" {
		t.Errorf("result = %+v, want settled/invoiced", out2)
	}
	if got := s2.store.(*fakeStore).invoices[inv2.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
}

// The check must stay a check: on a fractional invoice, anything other than the
// rounded gateway amount fails closed in BOTH paths — under-payment,
// over-payment, and the unrounded invoice figure itself.
func TestMidtransSettle_FractionalMismatchStillRejectedInBothPaths(t *testing.T) {
	cases := []struct {
		name  string
		gross string
	}{
		{"under-payment", "320.00"},
		{"over-payment", "322.00"},
		{"unrounded-invoice-amount", "320.50"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			_, inv, ref, _ := fracOrderWithRef(t, s)
			if _, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, tc.gross)); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
				t.Errorf("webhook err = %v, want ErrMidtransAmountMismatch", err)
			}
			if got := s.store.(*fakeStore).invoices[inv.ID].Status; got == "paid" {
				t.Error("mismatched webhook settled the invoice")
			}

			s2 := svc()
			o2, inv2, _, _ := fracOrderWithRef(t, s2)
			statusSvc(t, s2, tc.gross)
			if _, err := s2.SnapPaymentStatus(context.Background(), o2.ID, 42); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
				t.Errorf("poll err = %v, want ErrMidtransAmountMismatch", err)
			}
			if got := s2.store.(*fakeStore).invoices[inv2.ID].Status; got == "paid" {
				t.Error("mismatched recovery poll settled the invoice")
			}
		})
	}
}

// Same, on a whole-amount invoice: the pre-existing rejection is untouched.
func TestMidtransSettle_WholeAmountMismatchStillRejectedInBothPaths(t *testing.T) {
	for _, tc := range []struct{ name, gross string }{{"under-payment", "199.00"}, {"over-payment", "201.00"}} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			_, inv, ref := notifOrderWithRef(t, s)
			if _, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, tc.gross)); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
				t.Errorf("webhook err = %v, want ErrMidtransAmountMismatch", err)
			}
			if got := s.store.(*fakeStore).invoices[inv.ID].Status; got == "paid" {
				t.Error("mismatched webhook settled the invoice")
			}

			s2 := svc()
			o2, inv2, _ := notifOrderWithRef(t, s2)
			statusSvc(t, s2, tc.gross)
			if _, err := s2.SnapPaymentStatus(context.Background(), o2.ID, 42); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
				t.Errorf("poll err = %v, want ErrMidtransAmountMismatch", err)
			}
			if got := s2.store.(*fakeStore).invoices[inv2.ID].Status; got == "paid" {
				t.Error("mismatched recovery poll settled the invoice")
			}
		})
	}
}

// The unified fallback: an open invoice with a non-positive remaining balance
// is charged its TOTAL in both paths. The recovery poll used to have no
// fallback and rejected what the webhook accepted.
func TestMidtransSettle_NonPositiveRemainingFallsBackToTotalInBothPaths(t *testing.T) {
	for _, tc := range []struct {
		name      string
		remaining decimal.Decimal
	}{
		{"zero-remaining", decimal.Zero},
		{"negative-remaining", decimal.RequireFromString("-0.50")},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			_, inv, ref, _ := fracOrderWithRef(t, s)
			s.store.(*fakeStore).invoices[inv.ID].RemainingAmount = tc.remaining
			if out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "321.00")); err != nil || out != finance.MidtransOutcomeSettled {
				t.Errorf("webhook outcome = %q err = %v, want settled on the invoice total", out, err)
			}
			if got := s.store.(*fakeStore).invoices[inv.ID].Status; got != "paid" {
				t.Errorf("webhook invoice status = %s, want paid", got)
			}

			s2 := svc()
			o2, inv2, _, _ := fracOrderWithRef(t, s2)
			s2.store.(*fakeStore).invoices[inv2.ID].RemainingAmount = tc.remaining
			statusSvc(t, s2, "321.00")
			out, err := s2.SnapPaymentStatus(context.Background(), o2.ID, 42)
			if err != nil {
				t.Fatalf("poll err = %v, want settle on the invoice total", err)
			}
			if out.PaymentStatus != "settled" {
				t.Errorf("payment_status = %q, want settled on the invoice total", out.PaymentStatus)
			}
			if got := s2.store.(*fakeStore).invoices[inv2.ID].Status; got != "paid" {
				t.Errorf("poll invoice status = %s, want paid", got)
			}
		})
	}
}

// ...and the fallback did not degenerate into accepting anything: with a
// non-positive remaining balance, the remaining amount itself (0) is still a
// mismatch in both paths.
func TestMidtransSettle_NonPositiveRemainingStillRejectsRemainingAmount(t *testing.T) {
	s := svc()
	_, inv, ref, _ := fracOrderWithRef(t, s)
	s.store.(*fakeStore).invoices[inv.ID].RemainingAmount = decimal.Zero
	if _, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "0.00")); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
		t.Errorf("webhook err = %v, want ErrMidtransAmountMismatch", err)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got == "paid" {
		t.Error("zero-amount webhook settled the invoice")
	}

	s2 := svc()
	o2, inv2, _, _ := fracOrderWithRef(t, s2)
	s2.store.(*fakeStore).invoices[inv2.ID].RemainingAmount = decimal.Zero
	statusSvc(t, s2, "0.00")
	if _, err := s2.SnapPaymentStatus(context.Background(), o2.ID, 42); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
		t.Errorf("poll err = %v, want ErrMidtransAmountMismatch", err)
	}
	if got := s2.store.(*fakeStore).invoices[inv2.ID].Status; got == "paid" {
		t.Error("zero-amount recovery poll settled the invoice")
	}
}

// The send/compare rounding contract, pinned. If the integer Snap is told ever
// stops being the value both settle paths accept, captured payments strand
// again — that is the bug this suite exists for.
func TestSnapGrossAmount_SendAndCompareAgree(t *testing.T) {
	cases := []struct {
		invoice   string
		wantGross string
	}{
		{"200.00", "200"},
		{"200.01", "200"},
		{"200.25", "200"},
		{"200.49", "200"},
		{"200.50", "201"},
		{"200.51", "201"},
		{"200.75", "201"},
		{"200.99", "201"},
	}
	for _, tc := range cases {
		t.Run(tc.invoice, func(t *testing.T) {
			want := decimal.RequireFromString(tc.wantGross)
			inv := &Invoice{
				TotalAmount:     decimal.RequireFromString(tc.invoice),
				RemainingAmount: decimal.RequireFromString(tc.invoice),
			}
			if got := snapRoundedGross(invoicePayableAmount(inv)); !got.Equal(want) {
				t.Fatalf("rounded = %s, want %s", got, want)
			}
			if _, err := reconcileSnapGross(inv, "notification", want); err != nil {
				t.Errorf("rounded gateway amount %s rejected: %v", want, err)
			}
			if !decimal.RequireFromString(tc.invoice).Equal(want) {
				// The unrounded invoice figure is NOT what Midtrans reports, so
				// it must not be accepted as a substitute.
				if _, err := reconcileSnapGross(inv, "notification", decimal.RequireFromString(tc.invoice)); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
					t.Errorf("unrounded amount %s err = %v, want ErrMidtransAmountMismatch", tc.invoice, err)
				}
			}
		})
	}
	// And the same contract on the wire: a fractional invoice is SENT as 321.
	s := svc()
	_, inv, ref, st := fracOrderWithRef(t, s)
	if got, want := sentGross(t, st), snapRoundedGross(inv.RemainingAmount).IntPart(); got != want {
		t.Errorf("sent gross_amount = %d, want %d", got, want)
	}
	if _, err := reconcileSnapGross(inv, "notification", snapRoundedGross(inv.RemainingAmount)); err != nil {
		t.Errorf("sent amount %d not accepted by the webhook reconcile: %v", sentGross(t, st), err)
	}
	_ = ref
}

// --- the poll must book what reconcileSnapGross RETURNED ----------------------
//
// reconcileSnapGross returns the invoice figure the payment row must record
// (invoicePayableAmount). The webhook uses that return value for BOTH the
// payment row and the order advance. SnapPaymentStatus validated the amount
// through the same helper and then threw the result away, reading
// inv.RemainingAmount instead.
//
// The two are identical whenever the invoice carries an open balance, so the
// divergence shipped invisibly. With a NON-POSITIVE open balance they are not:
// the amount check passes against the invoice total, and then the payment row
// was booked with zero (or a negative) and the order advanced by it — while the
// invoice went to paid. The poll and the webhook must be the same function.

// orderPaid reads the order's paid_amount. The fake store applies a settle
// amount straight onto it, so a before/after diff IS the order advance.
func orderPaid(t *testing.T, s *Service, orderID int64) decimal.Decimal {
	t.Helper()
	o, err := s.Get(context.Background(), orderID)
	if err != nil {
		t.Fatal(err)
	}
	return o.PaidAmount
}

// fracTotal is the fracOrderWithRef invoice total: product 3 at 160.25 x 2.
// Midtrans is told a rounded 321 for it.
var fracTotal = decimal.RequireFromString("320.50")

// THE REGRESSION plus the unchanged normal case. The payment row amount and
// the order advance are asserted, not merely the absence of an error: booking
// zero/negative and still returning "settled" is the shape of the bug.
func TestSnapPaymentStatus_BooksReconciledPayableNotRemaining(t *testing.T) {
	for _, tc := range []struct {
		name      string
		remaining decimal.Decimal
		want      decimal.Decimal
	}{
		// No open balance: the payable falls back to the invoice total. The
		// old code booked 0 and -0.50 here and advanced the order by that.
		{"zero-remaining", decimal.Zero, fracTotal},
		{"negative-remaining", decimal.RequireFromString("-0.50"), fracTotal},
		// Unchanged normal partial case: the open balance IS the payable.
		{"positive-remaining", decimal.RequireFromString("120.25"), decimal.RequireFromString("120.25")},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			o, inv, _, _ := fracOrderWithRef(t, s)
			fs := s.store.(*fakeStore)
			fs.invoices[inv.ID].RemainingAmount = tc.remaining
			before := orderPaid(t, s, o.ID)
			// The provider echoes the ROUNDED amount Snap was told, which is the
			// rounded payable for this figure.
			statusSvc(t, s, snapRoundedGross(tc.want).StringFixed(2))
			out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
			if err != nil {
				t.Fatalf("poll err = %v, want a settle booked at %s", err, tc.want)
			}
			if out.PaymentStatus != "settled" {
				t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
			}
			if got := payAmountFor(t, fs, inv.ID); !got.Equal(tc.want) {
				t.Errorf("payment amount = %s, want %s (the reconciled payable, not remaining_amount %s)",
					got, tc.want, tc.remaining)
			}
			if got := orderPaid(t, s, o.ID).Sub(before); !got.Equal(tc.want) {
				t.Errorf("order advanced by %s, want %s (the same figure the payment row recorded)", got, tc.want)
			}
		})
	}
}

// THE INVARIANT THAT WAS BROKEN, pinned directly: the same invoice shape and
// the same provider-reported gross, settled through the webhook and through
// the recovery poll, must book the same payment amount. The two paths are the
// same money path; a divergence here is a ledger that disagrees with itself
// depending on which door the payment came through.
func TestSnapPaymentStatus_MatchesWebhookPaymentAmount(t *testing.T) {
	// Every case below reconciles to the same payable (320.50 -> gateway 321),
	// so the reported gross is identical across them and both paths: only the
	// booking amount can differ.
	for _, tc := range []struct {
		name      string
		remaining decimal.Decimal
	}{
		{"positive-remaining", fracTotal},
		{"zero-remaining", decimal.Zero},
		{"negative-remaining", decimal.RequireFromString("-0.50")},
	} {
		t.Run(tc.name, func(t *testing.T) {
			w := svc()
			_, winv, ref, _ := fracOrderWithRef(t, w)
			wfs := w.store.(*fakeStore)
			wfs.invoices[winv.ID].RemainingAmount = tc.remaining
			wantPayable := invoicePayableAmount(wfs.invoices[winv.ID])
			if out, err := w.HandleMidtransNotification(context.Background(), notifFor(ref, "321.00")); err != nil || out != finance.MidtransOutcomeSettled {
				t.Fatalf("webhook outcome = %q err = %v, want settled", out, err)
			}
			viaWebhook := payAmountFor(t, wfs, winv.ID)
			if !viaWebhook.Equal(wantPayable) {
				t.Fatalf("fixture: webhook booked %s, want the payable %s", viaWebhook, wantPayable)
			}

			p := svc()
			po, pinv, _, _ := fracOrderWithRef(t, p)
			pfs := p.store.(*fakeStore)
			pfs.invoices[pinv.ID].RemainingAmount = tc.remaining
			wBefore := orderPaid(t, p, po.ID)
			statusSvc(t, p, "321.00")
			if out, err := p.SnapPaymentStatus(context.Background(), po.ID, 42); err != nil || out.PaymentStatus != "settled" {
				t.Fatalf("poll result = %+v err = %v, want settled", out, err)
			}
			viaPoll := payAmountFor(t, pfs, pinv.ID)
			if !viaPoll.Equal(viaWebhook) {
				t.Errorf("poll booked %s, webhook booked %s — both settle paths must record one figure", viaPoll, viaWebhook)
			}
			wAfter := orderPaid(t, p, po.ID).Sub(wBefore)
			if !wAfter.Equal(viaWebhook) {
				t.Errorf("poll advanced the order by %s but booked %s — the row and the order must agree", wAfter, viaWebhook)
			}
		})
	}
}

// The check stays a check after the fix: a gross that is not the rounded
// payable still fails closed through the poll, and — beyond the error — books
// no payment row, does not mark the invoice paid, and does not move the order.
func TestSnapPaymentStatus_MismatchStillFailsClosed(t *testing.T) {
	for _, tc := range []struct{ name, gross string }{
		{"under-payment", "320.00"},
		{"over-payment", "322.00"},
		{"zero-remaining-amount", "0.00"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := svc()
			o, inv, _, _ := fracOrderWithRef(t, s)
			fs := s.store.(*fakeStore)
			// Non-positive open balance: the payable is the invoice total, so
			// every gross here is a mismatch and must be refused.
			fs.invoices[inv.ID].RemainingAmount = decimal.Zero
			before := orderPaid(t, s, o.ID)
			statusSvc(t, s, tc.gross)
			if _, err := s.SnapPaymentStatus(context.Background(), o.ID, 42); !errors.Is(err, finance.ErrMidtransAmountMismatch) {
				t.Fatalf("poll err = %v, want ErrMidtransAmountMismatch", err)
			}
			if got := fs.invoices[inv.ID].Status; got == "paid" {
				t.Errorf("invoice status = %s, want not paid", got)
			}
			noPaymentFor(t, fs, inv.ID)
			if got := orderPaid(t, s, o.ID).Sub(before); !got.IsZero() {
				t.Errorf("order advanced by %s, want 0", got)
			}
		})
	}
}

// --- recovery poll vs the rotated snap ref ------------------------------------
//
// SnapToken overwrites snap_order_ref on every retry, so the stored ref is
// only the NEWEST attempt. The webhook still settles a payment made on a
// superseded ref (InvoiceBySnapRef matches by prefix); the recovery poll used
// to ask about the stored ref alone, so a buyer who opened Snap, dismissed it,
// clicked pay again and then completed the FIRST transaction had captured money
// with an order stuck pending forever. The poll must ask the canonical
// first-issue ref too.

// retryRef books an order + invoice, issues the first token, then retries it —
// leaving a timestamped (rotated) snap_order_ref. Returns the invoice, the
// canonical first-issue ref, and the ref now stored in the database.
func retryRef(t *testing.T, s *Service) (*SalesOrder, *Invoice, string, string) {
	t.Helper()
	o, inv, firstRef := notifOrderWithRef(t, s)
	if _, err := s.SnapToken(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	fs := s.store.(*fakeStore)
	inv = fs.invoices[inv.ID]
	if inv.SnapOrderRef == nil || *inv.SnapOrderRef == firstRef {
		t.Fatal("retry did not rotate the snap ref")
	}
	// The canonical ref is built here from the invoice number + order id, NOT
	// read from the helper, so the format is pinned independently.
	canonical := "KM-" + inv.InvoiceNumber + "-" + itoa2(o.ID)
	if canonical != firstRef {
		t.Fatalf("first-issue ref = %q, want the canonical %q", firstRef, canonical)
	}
	return o, inv, canonical, *inv.SnapOrderRef
}

// perRefSvc wires the recovery poll onto a service whose per-ref status
// answers are supplied by the fixture.
func perRefSvc(t *testing.T, s *Service, byRef map[string]statusReply) *statusStubRT {
	t.Helper()
	st := &statusStubRT{byRef: byRef}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	return st
}

// THE REGRESSION: the stored ref is a rotated retry ref the buyer abandoned
// (pending) and the canonical one carries the captured payment. The poll must
// settle. Before the fix it asked about the pending ref only and answered
// "ignored" while Midtrans held the money.
func TestSnapPaymentStatus_SettlesFromSupersededCanonicalRef(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {status: "pending"},
		canonical: {status: "settlement"},
	})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("recovery poll errored on a paid superseded ref: %v", err)
	}
	if out.PaymentStatus != "settled" || out.OrderStatus != "invoiced" {
		t.Fatalf("result = %+v, want settled/invoiced", out)
	}
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid (money captured on the canonical ref)", got)
	}
	if got := payAmountFor(t, fs, inv.ID); !got.Equal(decimal.NewFromInt(200)) {
		t.Errorf("payment amount = %s, want 200", got)
	}
	if len(st.refs) != 2 || st.refs[0] != stored || st.refs[1] != canonical {
		t.Errorf("status refs = %v, want [%s %s]", st.refs, stored, canonical)
	}
}

// The settled case stops at the first ref that settles: no canonical probe
// after it. One status call, the payment booked.
func TestSnapPaymentStatus_StoredRefSettlesWithoutCanonicalProbe(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {status: "settlement"},
		canonical: {status: "settlement"},
	})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if st.calls != 1 || st.refs[0] != stored {
		t.Errorf("status calls = %v, want exactly one probe of the stored ref %s", st.refs, stored)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
	_ = canonical
}

// A ref the provider has never heard of is not a gateway failure. Neither ref
// known must answer with a not-settled outcome, never a 500, and must leave the
// invoice open.
func TestSnapPaymentStatus_UnknownRefsReportNotSettledWithoutError(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {notFound: true},
		canonical: {notFound: true},
	})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("unknown refs must not fail the poll: %v", err)
	}
	if out.PaymentStatus == "settled" {
		t.Fatalf("payment_status = %q, want a not-settled outcome", out.PaymentStatus)
	}
	// An unknown ref maps to pending, not failed: guessing "failed" would send
	// the cashier down the cancel path, which restocks, on a payment that may
	// still be in flight.
	if out.PaymentStatus != "pending" {
		t.Errorf("payment_status = %q, want pending (no transaction on any ref is not a failure)", out.PaymentStatus)
	}
	if st.calls != 2 {
		t.Errorf("status calls = %d, want 2 (both refs probed)", st.calls)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got == "paid" {
		t.Error("unknown refs settled the invoice")
	}
}

// The guard: the ref that settles must reconcile. A settled transaction whose
// amount does not match fails closed — the poll must NOT fall through and
// settle on the other ref just because that one's amount would have matched.
func TestSnapPaymentStatus_SettlingRefAmountMismatchFailsClosed(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		// The ref the poll tries first settles, but with a wrong amount. The
		// canonical ref would have matched — and must not be used instead.
		stored:    {status: "settlement", gross: "1.00"},
		canonical: {status: "settlement", gross: "200.00"},
	})
	_, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if !errors.Is(err, finance.ErrMidtransAmountMismatch) {
		t.Fatalf("err = %v, want ErrMidtransAmountMismatch", err)
	}
	if st.calls != 1 {
		t.Errorf("status calls = %d, want 1 (no fall-through to the matching ref)", st.calls)
	}
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got == "paid" {
		t.Error("mismatched settling ref settled the invoice")
	}
	for _, p := range fs.payments {
		if p.PayableType == InvoiceModel && p.PayableID == inv.ID {
			t.Error("a payment row was booked for a mismatched amount")
		}
	}
	_ = canonical
}

// A transport failure on the newest ref must not hide a capture on the older
// one: the poll still asks the canonical ref, and settles if that one is paid.
func TestSnapPaymentStatus_TransportErrorOnStoredRefStillTriesCanonical(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {transportErr: errors.New("connection reset by peer")},
		canonical: {status: "settlement"},
	})
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("transport error on the newest ref hid the capture: %v", err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if len(st.refs) != 2 || st.refs[0] != stored || st.refs[1] != canonical {
		t.Errorf("status refs = %v, want [%s %s]", st.refs, stored, canonical)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
}

// ...but when nothing settles, the real gateway failure still surfaces: an
// unreachable gateway is a 500, not a quiet "not paid".
func TestSnapPaymentStatus_TransportErrorWithNoCaptureStillFails(t *testing.T) {
	s := svc()
	o, _, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {transportErr: errors.New("connection reset by peer")},
		canonical: {transportErr: errors.New("connection reset by peer")},
	})
	if _, err := s.SnapPaymentStatus(context.Background(), o.ID, 42); err == nil {
		t.Fatal("err = nil, want the gateway failure to surface when nothing settles")
	}
	if st.calls != 2 {
		t.Errorf("status calls = %d, want 2 (both refs probed)", st.calls)
	}
}

// Settling from the superseded ref keeps the pre-existing guarantees: a second
// poll is a no-op that never touches the gateway again, and a late webhook for
// the old ref cannot un-pay the settled invoice.
func TestSnapPaymentStatus_SecondPollAfterSettleIsNoOp(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	st := perRefSvc(t, s, map[string]statusReply{
		stored:    {status: "pending"},
		canonical: {status: "settlement"},
	})
	if _, err := s.SnapPaymentStatus(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	fs := s.store.(*fakeStore)
	payments := len(fs.payments)
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "settled" {
		t.Errorf("second poll payment_status = %q, want settled (already closed)", out.PaymentStatus)
	}
	if st.calls != 2 {
		t.Errorf("status calls = %d, want 2 — a settled invoice short-circuits before the gateway", st.calls)
	}
	if len(fs.payments) != payments {
		t.Errorf("payment rows = %d, want %d (no double booking)", len(fs.payments), payments)
	}
	if got := fs.invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
	// Monotonicity: a late notification on the superseded ref is a no-op.
	if out, err := s.HandleMidtransNotification(context.Background(), notifFor(stored, "200.00")); err != nil || out != finance.MidtransOutcomeAlreadySettled {
		t.Errorf("late notification outcome = %q err = %v, want already-settled", out, err)
	}
	_ = canonical
}

// No ref at all is still "not_attempted" — the response contract is unchanged,
// and the gateway is not called.
func TestSnapPaymentStatus_NoRefIsNotAttempted(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	for _, inv := range fs.invoices {
		if inv.SalesOrderID == o.ID {
			inv.SnapOrderRef = nil
		}
	}
	st := perRefSvc(t, s, nil)
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != "not_attempted" {
		t.Errorf("payment_status = %q, want not_attempted", out.PaymentStatus)
	}
	if st.calls != 0 {
		t.Errorf("status calls = %d, want 0", st.calls)
	}
}

// --- written-off (cancelled) invoices must never be settled -------------------
//
// CancelOrderInvoices zeroes remaining_amount and marks the invoice
// 'cancelled', but it leaves total_amount AND snap_order_ref in place.
// InvoiceBySnapRef resolves that ref regardless of status BY DESIGN (a late
// duplicate for a paid invoice must still resolve so it can answer
// already-settled), and invoicePayableAmount falls back to total_amount exactly
// on the cancelled shape. So a capture that settles AFTER the order was
// cancelled arrives with a perfectly matching gross: the webhook used to flip
// cancelled -> paid, stamp paid_amount and book a payments row, and a
// deliberately forgiven debt became indistinguishable from a collected one.

// cancelInvoices runs the production cancel write, so the fixture cannot drift
// from CancelOrderInvoices.
func cancelInvoices(t *testing.T, s *Service, orderID int64) {
	t.Helper()
	if err := s.store.CancelOrderInvoices(context.Background(), nil, orderID); err != nil {
		t.Fatal(err)
	}
}

// noPaymentFor fails if any payment row was booked against the invoice.
func noPaymentFor(t *testing.T, fs *fakeStore, invoiceID int64) {
	t.Helper()
	for _, p := range fs.payments {
		if p.PayableType == InvoiceModel && p.PayableID == invoiceID {
			t.Fatalf("payment row booked against invoice %d: amount %s method %s", invoiceID, p.Amount, p.PaymentMethod)
		}
	}
}

// THE REGRESSION: a late, correctly-signed settlement for a CANCELLED invoice
// must not mark it paid, must not write a payment row, and must not leave the
// invoice looking collected.
func TestMidtransNotification_WrittenOffInvoiceIsNotResurrected(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	orderBefore, err := s.Get(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	// The buyer cancels after the Snap token was issued.
	cancelInvoices(t, s, o.ID)
	// inv aliases the stored row, so capture the values, not the pointer.
	statusBefore, remainingBefore, paidBefore := inv.Status, inv.RemainingAmount, inv.PaidAmount
	if statusBefore != "cancelled" || !remainingBefore.IsZero() {
		t.Fatalf("fixture: status = %s remaining = %s, want cancelled/0", statusBefore, remainingBefore)
	}
	if inv.SnapOrderRef == nil || *inv.SnapOrderRef != ref {
		t.Fatal("cancel must not clear snap_order_ref — that surviving ref is what lets a late capture resolve")
	}
	if !inv.TotalAmount.IsPositive() {
		t.Fatalf("total_amount = %s, want the billed figure left intact on the written-off row", inv.TotalAmount)
	}

	// The buyer pays anyway. Correct ref, correct amount, valid signature.
	out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "200.00"))
	if err != nil {
		t.Fatalf("a written-off capture must not be an error (Midtrans would retry it forever): %v", err)
	}
	if out != finance.MidtransOutcomeWrittenOff {
		t.Fatalf("outcome = %q, want %q", out, finance.MidtransOutcomeWrittenOff)
	}
	if inv.Status != "cancelled" {
		t.Errorf("invoice status = %q, want cancelled (a forgiven debt must stay forgiven)", inv.Status)
	}
	if !inv.PaidAmount.Equal(paidBefore) || !inv.RemainingAmount.Equal(remainingBefore) {
		t.Errorf("invoice amounts = paid %s remaining %s, want unchanged %s/%s",
			inv.PaidAmount, inv.RemainingAmount, paidBefore, remainingBefore)
	}
	noPaymentFor(t, fs, inv.ID)
	orderAfter, err := s.Get(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if orderAfter.Status != orderBefore.Status {
		t.Errorf("order status = %q, want %q (no settle happened, so nothing moved)", orderAfter.Status, orderBefore.Status)
	}
}

// The response contract, end to end through the handler: a written-off capture
// is a TERMINAL 2xx (so the provider stops delivering) and is distinguishable
// from every other outcome. A 409 would be a conflict that never resolves; an
// `ignored` would make the capture vanish with no trace.
func TestMidtransNotification_WrittenOffAnswersTerminal200OverHTTP(t *testing.T) {
	post := func(t *testing.T, s *Service, n finance.MidtransNotification) (int, string) {
		t.Helper()
		r := chi.NewRouter()
		r.Post("/midtrans/notifications", NewHandler(s).MidtransNotification)
		body, err := json.Marshal(n)
		if err != nil {
			t.Fatal(err)
		}
		res := httptest.NewRecorder()
		r.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/midtrans/notifications", bytes.NewReader(body)))
		var out struct {
			Data struct {
				Outcome string `json:"outcome"`
			} `json:"data"`
		}
		_ = json.Unmarshal(res.Body.Bytes(), &out)
		return res.Code, out.Data.Outcome
	}

	t.Run("cancelled", func(t *testing.T) {
		s := svc()
		o, _, ref := notifOrderWithRef(t, s)
		cancelInvoices(t, s, o.ID)
		code, outcome := post(t, s, notifFor(ref, "200.00"))
		if code != http.StatusOK {
			t.Fatalf("status = %d, want 200 — a non-2xx makes Midtrans redeliver a capture that can never settle", code)
		}
		if outcome != finance.MidtransOutcomeWrittenOff {
			t.Fatalf("outcome = %q, want %q", outcome, finance.MidtransOutcomeWrittenOff)
		}
		for _, other := range []string{
			finance.MidtransOutcomeIgnored, finance.MidtransOutcomeAlreadySettled,
			finance.MidtransOutcomeSettled, finance.MidtransOutcomeFailedRecorded,
		} {
			if outcome == other {
				t.Errorf("outcome %q collides with %q — the operator cannot tell them apart", outcome, other)
			}
		}
	})

	// A duplicate notification for a genuinely paid invoice is still
	// already-settled, NOT an error: turning it into one would make Midtrans
	// retry a successful settlement forever.
	t.Run("paid replay stays already-settled", func(t *testing.T) {
		s := svc()
		o, _, ref := notifOrderWithRef(t, s)
		if out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "200.00")); err != nil || out != finance.MidtransOutcomeSettled {
			t.Fatalf("first settlement = %q err = %v, want settled", out, err)
		}
		fs := s.store.(*fakeStore)
		payments := len(fs.payments)
		code, outcome := post(t, s, notifFor(ref, "200.00"))
		if code != http.StatusOK {
			t.Fatalf("status = %d, want 200 (already-settled must stay idempotent)", code)
		}
		if outcome != finance.MidtransOutcomeAlreadySettled {
			t.Errorf("outcome = %q, want %q", outcome, finance.MidtransOutcomeAlreadySettled)
		}
		if len(fs.payments) != payments {
			t.Errorf("payment rows = %d, want %d (no double booking)", len(fs.payments), payments)
		}
		_ = o
	})
}

// The whole settleable-status rule in one table: open invoices settle, a
// settled one is idempotent, a written-off one is refused and named.
func TestMidtransNotification_SettleableStatusRule(t *testing.T) {
	cases := []struct {
		status    string
		paid      string
		remaining string
		gross     string
		want      string
	}{
		{status: "unpaid", paid: "0", remaining: "200", gross: "200.00", want: finance.MidtransOutcomeSettled},
		{status: "partial", paid: "50", remaining: "150", gross: "150.00", want: finance.MidtransOutcomeSettled},
		{status: "paid", paid: "200", remaining: "0", gross: "200.00", want: finance.MidtransOutcomeAlreadySettled},
		{status: "cancelled", paid: "0", remaining: "0", gross: "200.00", want: finance.MidtransOutcomeWrittenOff},
	}
	for _, tc := range cases {
		t.Run(tc.status, func(t *testing.T) {
			s := svc()
			_, inv, ref := notifOrderWithRef(t, s)
			inv.Status = tc.status
			inv.PaidAmount = decimal.RequireFromString(tc.paid)
			inv.RemainingAmount = decimal.RequireFromString(tc.remaining)
			out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, tc.gross))
			if err != nil {
				t.Fatalf("err = %v, want a terminal outcome (no error — Midtrans retries every non-2xx)", err)
			}
			if out != tc.want {
				t.Fatalf("outcome = %q, want %q", out, tc.want)
			}
		})
	}
}

// The third shape: cancelling a PARTIALLY paid invoice leaves paid_amount > 0
// with remaining = 0 and status = 'cancelled'. The status guard covers it — no
// special case — and the late capture must not top paid_amount up to the total
// as though the rest had been collected.
func TestMidtransNotification_PartiallyPaidThenCancelledIsNotResurrected(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	// 50 of 200 lands, then the order is cancelled.
	inv.Status = "partial"
	inv.PaidAmount = decimal.NewFromInt(50)
	inv.RemainingAmount = decimal.NewFromInt(150)
	cancelInvoices(t, s, o.ID)
	if inv.Status != "cancelled" || !inv.RemainingAmount.IsZero() || inv.PaidAmount.IsZero() {
		t.Fatalf("fixture: status = %s remaining = %s paid = %s, want cancelled/0/50",
			inv.Status, inv.RemainingAmount, inv.PaidAmount)
	}
	// The gross is the ORIGINAL Snap total. Had the invoice stayed open the
	// payable figure would be the 150 balance and this would be an amount
	// conflict (409) instead — so the outcome assertion below is what proves
	// the STATUS guard fired, not the amount check.
	out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "200.00"))
	if err != nil {
		t.Fatalf("err = %v, want a terminal 200 outcome", err)
	}
	if out != finance.MidtransOutcomeWrittenOff {
		t.Fatalf("outcome = %q, want %q", out, finance.MidtransOutcomeWrittenOff)
	}
	if inv.Status != "cancelled" {
		t.Errorf("invoice status = %q, want cancelled", inv.Status)
	}
	if !inv.PaidAmount.Equal(decimal.NewFromInt(50)) {
		t.Errorf("paid_amount = %s, want 50 unchanged (the capture was never collected into this ledger)", inv.PaidAmount)
	}
	noPaymentFor(t, fs, inv.ID)
	_ = o
}

// The guard runs BEFORE the amount reconciliation — the same order
// finance.PayInvoice uses (status, then size). A written-off capture is not
// settleable at any amount, so it must never be answered with the 409 an amount
// mismatch produces: that conflict can never resolve and the provider would
// retry it indefinitely.
func TestMidtransNotification_WrittenOffIsNotAnAmountConflict(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	cancelInvoices(t, s, o.ID)
	out, err := s.HandleMidtransNotification(context.Background(), notifFor(ref, "1.00"))
	if err != nil {
		t.Fatalf("err = %v, want nil — a wrong amount on a written-off invoice is still terminal, not a 409", err)
	}
	if out != finance.MidtransOutcomeWrittenOff {
		t.Fatalf("outcome = %q, want %q", out, finance.MidtransOutcomeWrittenOff)
	}
	if inv.Status != "cancelled" {
		t.Errorf("invoice status = %q, want cancelled", inv.Status)
	}
	noPaymentFor(t, s.store.(*fakeStore), inv.ID)
	_ = o
}

// Defence in depth, at the statement. internal/sales has no database-backed
// harness (the sales store is a hand-written fake that never interprets SQL),
// so this pins the STATEMENT rather than a row: MarkInvoicePaid must carry the
// same settleable predicate the service enforces, or a future caller that
// forgets the service guard flips a cancelled invoice back to paid.
//
// NOT a behavioural test: proving the UPDATE genuinely cannot match a
// cancelled row needs a real database (see the DATABASE_URL-gated
// internal/finance/repository_integration_test.go for that pattern).
func TestRepositoryMarkInvoicePaid_RefusesWrittenOffInvoice(t *testing.T) {
	tx := &fakeTx{}
	if _, err := (&Repository{}).MarkInvoicePaid(context.Background(), tx, 7, decimal.NewFromInt(200)); err != nil {
		t.Fatal(err)
	}
	stmt := strings.Join(strings.Fields(tx.lastSQL()), " ")
	clause := regexp.MustCompile(`status IN \(([^)]*)\)`).FindStringSubmatch(stmt)
	if clause == nil {
		t.Fatalf("MarkInvoicePaid carries no status predicate, so it can flip a cancelled invoice: %s", stmt)
	}
	admitted := map[string]bool{}
	for _, s := range strings.Split(clause[1], ",") {
		admitted[strings.Trim(strings.TrimSpace(s), "'")] = true
	}
	for _, want := range []string{"unpaid", "partial"} {
		if !admitted[want] {
			t.Errorf("MarkInvoicePaid no longer admits %q invoices: %s", want, stmt)
		}
	}
	for _, refused := range []string{"cancelled", "paid"} {
		if admitted[refused] {
			t.Errorf("MarkInvoicePaid still admits %q invoices: %s", refused, stmt)
		}
	}
	if len(admitted) != 2 {
		t.Errorf("MarkInvoicePaid admits %v, want exactly the settleable statuses", admitted)
	}
}

// The recovery poll: a written-off order must not be told it is settled. No
// money is written either way, but claiming settlement for a forgiven debt is
// a lie the buyer acts on.
func TestSnapPaymentStatus_WrittenOffInvoiceReportsWrittenOff(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	cancelInvoices(t, s, o.ID)
	// Would answer "settlement" for any ref: the poll must not even ask.
	st := perRefSvc(t, s, nil)
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus != finance.MidtransOutcomeWrittenOff {
		t.Fatalf("payment_status = %q, want %q (a written-off order is not settled)", out.PaymentStatus, finance.MidtransOutcomeWrittenOff)
	}
	if st.calls != 0 {
		t.Errorf("status calls = %d, want 0 — a written-off invoice must not reach the gateway", st.calls)
	}
	if inv.Status != "cancelled" {
		t.Errorf("invoice status = %q, want cancelled", inv.Status)
	}
	noPaymentFor(t, fs, inv.ID)
}

// An order cancelled before it was ever invoiced has no invoice row at all.
// OpenInvoiceForOrder and InvoiceForOrder both answer ErrNotFound, and neither
// means "the buyer paid" — the gateway was never asked.
func TestSnapPaymentStatus_NoInvoiceIsNotAttemptedNotSettled(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	delete(s.store.(*fakeStore).invoices, inv.ID)
	st := perRefSvc(t, s, nil)
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if out.PaymentStatus == "settled" {
		t.Fatalf("payment_status = %q, want a not-settled outcome for an order that was never invoiced", out.PaymentStatus)
	}
	if out.PaymentStatus != "not_attempted" {
		t.Errorf("payment_status = %q, want not_attempted (no invoice was ever billed)", out.PaymentStatus)
	}
	if st.calls != 0 {
		t.Errorf("status calls = %d, want 0", st.calls)
	}
}

// --- every gateway call is bounded in time ------------------------------------
//
// Both Midtrans calls went out on http.DefaultClient, which has NO timeout at
// all: a provider that accepted the connection and then stalled made the call
// wait forever. On the recovery poll that is a production outage, not a latency
// bug — the call happens with an OPEN transaction holding FOR UPDATE on the
// invoice row, so every stalled buyer pinned a row lock and a pooled connection
// until the pool was exhausted. The deadline is ours (midtransHTTPTimeout) so a
// stalled provider costs 5s, and it is classified HARD (errGatewayTimeout): a
// missed deadline says nothing about whether the buyer paid, so the invoice is
// retried on a later pass instead of being reported as a ref the gateway never
// saw, which would mark a genuinely-paid invoice un-settleable.

const (
	// stallTimeout replaces the 5s production deadline in the stalling
	// fixtures. It goes in through WithHTTPTimeout — an injectable duration on
	// the service, the same kind of test seam as WithHTTPClient — so no
	// package-level var is mutated and a production service still gets 5s.
	stallTimeout = 50 * time.Millisecond
	// stallCallerCap bounds the CALLER's context well above stallTimeout. With
	// the fix the service deadline always fires first; without it this cap is
	// what finally breaks the hang, so a regression fails an assertion instead
	// of wedging the test binary.
	stallCallerCap = 3 * time.Second
)

// stallCtx is the caller's context for the stalling tests.
func stallCtx(t *testing.T) context.Context {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), stallCallerCap)
	t.Cleanup(cancel)
	return ctx
}

// stallRT is a provider that accepts the connection and then goes quiet: the
// failure an unbounded client hangs on forever. It blocks until the REQUEST
// context is cancelled — what a real stalled server does once the client gives
// up — and once release is closed it answers instead, so a blocked call leaves
// no goroutine behind.
type stallRT struct {
	// release is closed by the test's cleanup to unblock any waiter.
	release chan struct{}
	// stallFirst stalls only the FIRST call and answers the rest, so a test
	// can prove a timeout on one ref does not hide a capture on another.
	stallFirst bool
	after      http.RoundTripper
	calls      int
}

func newStallRT(t *testing.T, stallFirst bool, after http.RoundTripper) *stallRT {
	t.Helper()
	s := &stallRT{release: make(chan struct{}), stallFirst: stallFirst, after: after}
	t.Cleanup(func() { close(s.release) })
	return s
}

func (s *stallRT) RoundTrip(r *http.Request) (*http.Response, error) {
	s.calls++
	if s.calls == 1 || !s.stallFirst {
		select {
		case <-s.release:
		case <-r.Context().Done():
			return nil, r.Context().Err()
		}
	}
	if s.after != nil {
		return s.after.RoundTrip(r)
	}
	return jsonResponse(http.StatusOK, `{"status_code":"200","transaction_status":"settlement","fraud_status":"","payment_type":"gopay","transaction_id":"tx-9","gross_amount":"200.00"}`), nil
}

// stallingProvider is an httptest server that accepts and never answers. The
// handler returns when the client's request context dies, so srv.Close() can
// never block on a request the client already abandoned.
func stallingProvider(t *testing.T) *httptest.Server {
	t.Helper()
	release := make(chan struct{})
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-release:
		case <-r.Context().Done():
		}
	}))
	t.Cleanup(func() { close(release); srv.Close() })
	return srv
}

// The bound is pinned in one place so it cannot drift silently: 5s on the
// shared default client (which also covers the response BODY read), and the
// fallback for a service nobody injected a client into — which is exactly how
// production is wired.
func TestSnapGatewayDeadlineIsFiveSeconds(t *testing.T) {
	if midtransHTTPTimeout != 5*time.Second {
		t.Errorf("midtransHTTPTimeout = %s, want 5s", midtransHTTPTimeout)
	}
	if snapHTTPClient.Timeout != midtransHTTPTimeout {
		t.Errorf("snapHTTPClient.Timeout = %s, want %s", snapHTTPClient.Timeout, midtransHTTPTimeout)
	}
	if got := (&Service{}).snapTimeout(); got != midtransHTTPTimeout {
		t.Errorf("snapTimeout() unconfigured = %s, want %s", got, midtransHTTPTimeout)
	}
	if got := (&Service{httpTimeout: 7 * time.Second}).snapTimeout(); got != 7*time.Second {
		t.Errorf("snapTimeout() after WithHTTPTimeout(7s) = %s, want 7s", got)
	}
	if got := (&Service{}).snapClient(); got != snapHTTPClient {
		t.Error("a service with no injected client must fall back to the bounded client, never http.DefaultClient")
	}
}

// THE REGRESSION, live path: no injected client, a real connection to a server
// that accepts and never answers. Bounded in time, hard, nothing settled.
func TestSnapPaymentStatus_StalledProviderTimesOutOnDefaultClient(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	srv := stallingProvider(t)
	// WithHTTPClient(nil) is what "no caller injected a client" looks like,
	// which is how main.go builds the service.
	s.WithHTTPClient(nil).
		WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: srv.URL}).
		WithHTTPTimeout(stallTimeout)

	start := time.Now()
	out, err := s.SnapPaymentStatus(stallCtx(t), o.ID, 42)
	elapsed := time.Since(start)

	if out != nil {
		t.Errorf("result = %+v, want nil: a timeout is a hard failure, not a not-settled outcome", out)
	}
	if !errors.Is(err, errGatewayTimeout) {
		t.Fatalf("err = %v, want a hard timeout (errGatewayTimeout)", err)
	}
	if errors.Is(err, errSnapRefUnknown) {
		t.Error("a timeout was classified as an unknown ref — the invoice would be skipped as un-settleable")
	}
	if elapsed > stallCallerCap {
		t.Errorf("status call took %s, want it bounded by the injected %s", elapsed, stallTimeout)
	}
	// Retryable: no settlement, no payment row, the invoice is still open.
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got == "paid" {
		t.Error("a timed-out poll settled the invoice")
	}
	noPaymentFor(t, fs, inv.ID)
}

// Same failure through the injected-client path, both refs stalling. The
// OUTCOME is what matters: a hard error, never the soft not-settled answer the
// poll gives for refs Midtrans does not know.
func TestSnapPaymentStatus_StalledProviderIsHardNotSoftSkip(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	rt := newStallRT(t, false, nil)
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: rt}).
		WithHTTPTimeout(stallTimeout)

	out, err := s.SnapPaymentStatus(stallCtx(t), o.ID, 42)
	if out != nil {
		t.Errorf("result = %+v, want nil on a timeout", out)
	}
	if !errors.Is(err, errGatewayTimeout) {
		t.Fatalf("err = %v, want a hard timeout so the invoice is retried", err)
	}
	if errors.Is(err, errSnapRefUnknown) {
		t.Error("timeout landed on the soft errSnapRefUnknown branch")
	}
	if rt.calls != 2 {
		t.Errorf("status calls = %d, want 2 (both refs probed, both timed out)", rt.calls)
	}
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got == "paid" {
		t.Error("a timed-out poll settled the invoice")
	}
	noPaymentFor(t, fs, inv.ID)
}

// A timeout is transient, so it must not stop the poll: the capture on the
// canonical ref is still found after the newest one times out.
func TestSnapPaymentStatus_TimeoutOnNewestRefStillTriesCanonical(t *testing.T) {
	s := svc()
	o, inv, canonical, stored := retryRef(t, s)
	after := &statusStubRT{status: "settlement"}
	rt := newStallRT(t, true, after)
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: rt}).
		WithHTTPTimeout(stallTimeout)

	out, err := s.SnapPaymentStatus(stallCtx(t), o.ID, 42)
	if err != nil {
		t.Fatalf("a timeout on the newest ref hid the canonical capture: %v", err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if len(after.refs) != 1 || after.refs[0] != canonical {
		t.Errorf("probed after the timeout = %v, want exactly the canonical ref %s", after.refs, canonical)
	}
	if got := s.store.(*fakeStore).invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
	_ = stored
}

// Precedence: an explicitly injected client wins and is used exactly as given.
// example.test would not resolve, so a settled invoice proves the injected
// transport ran and was not replaced by the default.
func TestSnapGatewayInjectedClientWinsOverDefault(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	st := &statusStubRT{status: "settlement"}
	injected := &http.Client{Transport: st, Timeout: 7 * time.Second}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(injected)
	if got := s.snapClient(); got != injected {
		t.Fatal("snapClient() is not the injected client")
	}
	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("call through the injected client failed: %v", err)
	}
	if out.PaymentStatus != "settled" {
		t.Errorf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if st.calls == 0 {
		t.Error("the injected transport was never used")
	}
	if injected.Timeout != 7*time.Second {
		t.Errorf("injected client Timeout = %s, want 7s: an injected client is never rewritten", injected.Timeout)
	}
}

// Respecting an injected client is not trusting it to be bounded: a bare client
// with no Timeout of its own still gets the service deadline, so a caller
// cannot reintroduce the outage by injecting one.
func TestSnapGatewayInjectedUnboundedClientIsStillBounded(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	rt := newStallRT(t, false, nil)
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: rt}).
		WithHTTPTimeout(stallTimeout)

	start := time.Now()
	_, err := s.SnapPaymentStatus(stallCtx(t), o.ID, 42)
	if !errors.Is(err, errGatewayTimeout) {
		t.Fatalf("err = %v, want a hard timeout even though the injected client has no Timeout", err)
	}
	if elapsed := time.Since(start); elapsed > stallCallerCap {
		t.Errorf("call took %s, want it bounded by %s", elapsed, stallTimeout)
	}
}

// The injected client's own tighter deadline still governs — ours is a ceiling,
// not an override — and it is classified hard like any other timeout.
func TestSnapGatewayInjectedClientTimeoutWins(t *testing.T) {
	s := svc()
	o, _, _ := notifOrderWithRef(t, s)
	rt := newStallRT(t, false, nil)
	// No WithHTTPTimeout: the service deadline is the 5s default here, so only
	// the injected client's own Timeout can end this call quickly.
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: rt, Timeout: stallTimeout})

	start := time.Now()
	_, err := s.SnapPaymentStatus(stallCtx(t), o.ID, 42)
	elapsed := time.Since(start)
	if !errors.Is(err, errGatewayTimeout) {
		t.Fatalf("err = %v, want a hard timeout from the injected client deadline", err)
	}
	if elapsed > 2*time.Second {
		t.Errorf("call took %s, want the injected %s to fire well before the 5s service default", elapsed, stallTimeout)
	}
}

// The other call site is bounded the same way: a stalled Snap token request
// used to pin the buyer's request and a pooled connection indefinitely.
func TestSnapToken_StalledProviderIsBounded(t *testing.T) {
	s := svc()
	o, _ := snapOrderWithInvoice(t, s)
	rt := newStallRT(t, false, nil)
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", SnapBaseURL: "http://example.test/snap"}).
		WithHTTPClient(&http.Client{Transport: rt}).
		WithHTTPTimeout(stallTimeout)

	start := time.Now()
	res, err := s.SnapToken(stallCtx(t), o.ID, 42)
	elapsed := time.Since(start)
	if res != nil {
		t.Errorf("result = %+v, want nil on a timeout", res)
	}
	if !errors.Is(err, errGatewayTimeout) {
		t.Fatalf("err = %v, want a hard timeout", err)
	}
	if elapsed > stallCallerCap {
		t.Errorf("token request took %s, want it bounded by %s", elapsed, stallTimeout)
	}
}

// The happy path is unchanged: through the same bounded default client the
// recovery poll still settles, so the new deadline breaks nothing.
func TestSnapPaymentStatus_DefaultClientSettlesOnTime(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(w, `{"status_code":"200","transaction_status":"settlement","fraud_status":"","payment_type":"gopay","transaction_id":"tx-9","gross_amount":"`+inv.RemainingAmount.StringFixed(2)+`"}`)
	}))
	defer srv.Close()
	s.WithHTTPClient(nil).WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: srv.URL})

	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("settlement through the default client failed: %v", err)
	}
	if out.PaymentStatus != "settled" || out.OrderStatus != "invoiced" {
		t.Errorf("result = %+v, want settled/invoiced", out)
	}
	fs := s.store.(*fakeStore)
	if got := fs.invoices[inv.ID].Status; got != "paid" {
		t.Errorf("invoice status = %s, want paid", got)
	}
	if got := payAmountFor(t, fs, inv.ID); !got.Equal(decimal.NewFromInt(200)) {
		t.Errorf("payment amount = %s, want 200", got)
	}
}

// tillPaymentStatus is the boundary between two vocabularies, and getting it
// wrong is user-visible at the till.
//
// The reconciler collapses every non-terminal provider state to "ignored",
// because "ignored" means "nothing to settle". That is correct for a background
// sweep and wrong for a POS polling every few seconds: the POS showed the
// cashier "ignored", which reads as a dead transaction rather than "still
// waiting for the customer to scan".
//
// The dangerous direction is over-confident, not cosmetic. Reporting "failed"
// for an unrecognised ref sends the cashier down the cancel path, which
// RESTOCKS and cancels a provider transaction that may still settle — so
// anything unrecognised must read as pending.
func TestTillPaymentStatus(t *testing.T) {
	cases := []struct {
		action string
		want   string
		why    string
	}{
		{finance.MidtransActionIgnored, "pending", "provider answered pending; keep waiting"},
		{"", "pending", "empty is unknown, and unknown must not cancel a live payment"},
		{"something-new", "pending", "a provider state we have not seen must not fail a payment"},
		{finance.MidtransActionSettled, "settled", "already handled before this runs, but pass it through"},
		{finance.MidtransActionFailed, "failed", "deny/cancel/expire/failure all mean retry is available"},
		{"  FAILED  ", "failed", "provider casing and padding are not load-bearing"},
	}
	for _, tc := range cases {
		if got := tillPaymentStatus(tc.action); got != tc.want {
			t.Errorf("tillPaymentStatus(%q) = %q, want %q — %s", tc.action, got, tc.want, tc.why)
		}
	}
}

// CONCURRENCY & RACE GUARDRAILS
// A concurrent caller that won the row between locked read and MarkInvoicePaid
// causes MarkInvoicePaid to match 0 rows. All paths (webhook, POS poll, B2B poll)
// must safely detect marked == 0, abort settlement without error, and book NO duplicate payment.

func TestMidtransNotification_ZeroRowMarkPaidReturnsAlreadySettledAndBooksNoPayment(t *testing.T) {
	s := svc()
	_, inv, ref := notifOrderWithRef(t, s)
	gross := inv.RemainingAmount.StringFixed(2)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionID: "tx-race-1", TransactionStatus: "settlement",
		StatusCode: "200", GrossAmount: gross, PaymentType: "gopay",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}
	fs := s.store.(*fakeStore)
	fs.markPaidZeroRows = true

	out, err := s.HandleMidtransNotification(context.Background(), n)
	if err != nil {
		t.Fatalf("unexpected error on concurrent race: %v", err)
	}
	if out != finance.MidtransOutcomeAlreadySettled {
		t.Fatalf("outcome = %q, want already-settled", out)
	}
	if len(fs.payments) != 0 {
		t.Fatalf("payments count = %d, want 0 (loser of race must not book payment)", len(fs.payments))
	}
}

func TestSnapPOSStatus_ZeroRowMarkPaidReturnsSettledAndBooksNoPayment(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	o.OrderType = "pos"
	fs.orders[o.ID].OrderType = "pos"
	fs.markPaidZeroRows = true
	statusSvc(t, s, inv.RemainingAmount.StringFixed(2))

	out, err := s.SnapPOSStatus(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("unexpected error on concurrent race: %v", err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if len(fs.payments) != 0 {
		t.Fatalf("payments count = %d, want 0 (loser of race must not book payment)", len(fs.payments))
	}
}

func TestSnapPaymentStatus_ZeroRowMarkPaidReturnsSettledAndBooksNoPayment(t *testing.T) {
	s := svc()
	o, inv, _ := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	fs.markPaidZeroRows = true
	statusSvc(t, s, inv.RemainingAmount.StringFixed(2))

	out, err := s.SnapPaymentStatus(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatalf("unexpected error on concurrent race: %v", err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment_status = %q, want settled", out.PaymentStatus)
	}
	if len(fs.payments) != 0 {
		t.Fatalf("payments count = %d, want 0 (loser of race must not book payment)", len(fs.payments))
	}
}

func TestSettleOrderPayment_ClampedAtTotal(t *testing.T) {
	s := svc()
	o, _ := snapOrderWithInvoice(t, s)
	fs := s.store.(*fakeStore)
	// Order total is 200. Settle twice with 200.
	if err := fs.SettleOrderPayment(context.Background(), nil, o.ID, decimal.NewFromInt(200)); err != nil {
		t.Fatal(err)
	}
	if err := fs.SettleOrderPayment(context.Background(), nil, o.ID, decimal.NewFromInt(200)); err != nil {
		t.Fatal(err)
	}
	got := fs.orders[o.ID]
	if got.PaidAmount.GreaterThan(got.TotalAmount) {
		t.Fatalf("paid_amount = %s exceeded total_amount = %s", got.PaidAmount, got.TotalAmount)
	}
	if !got.PaidAmount.Equal(got.TotalAmount) {
		t.Fatalf("paid_amount = %s, want %s", got.PaidAmount, got.TotalAmount)
	}
}

// TestMidtrans_ConcurrentWebhookAndPollRaceSettleOnce simulates a real-world scenario
// where Midtrans sends 4 webhook retries and 4 cashier POS status polls race simultaneously
// on the same order/invoice.
// Guardrails MUST guarantee:
// 1. Exactly 1 payment row is inserted into payments table.
// 2. Invoice remaining is 0, status is paid.
// 3. Sales order paid_amount == total_amount (not 8x total_amount).
// 4. All 8 concurrent calls return successful outcomes (settled or already-settled), 0 errors.
func TestMidtrans_ConcurrentWebhookAndPollRaceSettleOnce(t *testing.T) {
	s := svc()
	o, inv, ref := notifOrderWithRef(t, s)
	fs := s.store.(*fakeStore)
	o.OrderType = "pos"
	fs.orders[o.ID].OrderType = "pos"
	raceStore := &sweepRaceStore{fakeStore: fs}

	gross := inv.RemainingAmount.StringFixed(2)
	n := finance.MidtransNotification{
		OrderID: ref, TransactionID: "tx-concurrent-1", TransactionStatus: "settlement",
		StatusCode: "200", GrossAmount: gross, PaymentType: "gopay",
		SignatureKey: midtransSign(ref, "200", gross, "test-server-key"),
	}

	totalRoutines := 8
	var wg sync.WaitGroup
	errs := make([]error, totalRoutines)
	outcomes := make([]string, totalRoutines)

	for i := 0; i < totalRoutines; i++ {
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			svcInstance := NewService(raceStore, &lockedSeq{}, time.UTC)
			svcInstance.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
				WithHTTPClient(&http.Client{Transport: &statusStubRT{status: "settlement"}})

			if idx%2 == 0 {
				// Webhook concurrent call
				out, err := svcInstance.HandleMidtransNotification(context.Background(), n)
				errs[idx] = err
				outcomes[idx] = out
			} else {
				// POS status poll concurrent call
				res, err := svcInstance.SnapPOSStatus(context.Background(), o.ID)
				errs[idx] = err
				if res != nil {
					outcomes[idx] = res.PaymentStatus
				}
			}
		}(i)
	}
	wg.Wait()

	for i, err := range errs {
		if err != nil {
			t.Fatalf("goroutine %d failed: %v", i, err)
		}
	}

	// Verify only 1 payment row exists
	raceStore.mu.Lock()
	payCount := len(fs.payments)
	finalOrder := *fs.orders[o.ID]
	finalInv := *fs.invoices[inv.ID]
	raceStore.mu.Unlock()

	if payCount != 1 {
		t.Fatalf("payments count = %d, want exactly 1 (duplicate payments prevented)", payCount)
	}
	if finalInv.Status != "paid" || !finalInv.RemainingAmount.IsZero() {
		t.Fatalf("invoice status = %s remaining = %s, want paid/0", finalInv.Status, finalInv.RemainingAmount)
	}
	if !finalOrder.PaidAmount.Equal(finalOrder.TotalAmount) {
		t.Fatalf("order paid_amount = %s, want %s (uncapped accumulation prevented)", finalOrder.PaidAmount, finalOrder.TotalAmount)
	}
}

// --- OrderQRIS: the ERP pay dialog's in-page Core API charge -----------------

// qrisStatusRT answers the STATUS endpoint (and only it) with a canned qris
// payload; any charge request fails the test — these tests pin what OrderQRIS
// must NOT do (stack a second charge on a live or settled transaction).
type qrisStatusRT struct {
	t      *testing.T
	key    string
	status string // transaction_status value
	// charges counts /v2/charge hits; OrderQRIS may only hit it when the
	// previous transaction is failed/expired or unknown.
	charges int
}

func (s *qrisStatusRT) RoundTrip(r *http.Request) (*http.Response, error) {
	u, _, ok := r.BasicAuth()
	if !ok || u != s.key {
		s.t.Errorf("basic-auth user = %q, want configured server key", u)
	}
	if strings.HasSuffix(r.URL.Path, "/status") {
		return jsonResponse(http.StatusOK, fmt.Sprintf(
			`{"status_code":"200","transaction_status":%q,"fraud_status":"none","payment_type":"qris",
			  "transaction_id":"txn-q1","gross_amount":"200.00","qr_string":"0002010102",
			  "expiry_time":"2026-10-01 01:30:00",
			  "actions":[{"name":"generate-qr-code","method":"GET","url":"https://api.example/v2/qris/txn-q1/qr-code"}]}`,
			s.status)), nil
	}
	if strings.HasSuffix(r.URL.Path, "/v2/charge") {
		s.charges++
		return jsonResponse(http.StatusCreated, `{
			"status_code":"201","status_message":"Qris transaction is created",
			"transaction_id":"txn-new","gross_amount":"200.00","payment_type":"qris",
			"transaction_status":"pending","qr_string":"0002010999",
			"expiry_time":"2026-10-01 02:00:00",
			"actions":[{"name":"generate-qr-code","method":"GET","url":"https://api.example/v2/qris/txn-new/qr-code"}]
		}`), nil
	}
	s.t.Errorf("unexpected gateway path %s", r.URL.Path)
	return jsonResponse(http.StatusNotFound, `{}`), nil
}

func TestOrderQRIS_ChargesCoreAPIReturnsInPageQR(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := &qrisStatusRT{t: t, key: "test-server-key"}
	s.WithMidtrans(MidtransConfig{
		ServerKey:       "test-server-key",
		CoreBaseURL:     "http://example.test/v2",
		NotificationURL: "https://shop.example/api/v1/midtrans/notifications",
	}).WithHTTPClient(&http.Client{Transport: st})

	out, err := s.OrderQRIS(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("OrderQRIS err = %v, want nil", err)
	}
	if out.PaymentStatus != "pending" || out.QRURL == "" {
		t.Fatalf("out = %+v, want a pending in-page QR", out)
	}
	if !strings.Contains(out.QRURL, "/qr-code") {
		t.Errorf("qr url = %q, want the provider's generate-qr-code action", out.QRURL)
	}
	if st.charges != 1 {
		t.Errorf("charges = %d, want exactly 1 (fresh invoice, first charge)", st.charges)
	}
	if out.ExpiresAt.IsZero() {
		t.Error("expires_at zero — expiry_time parsed with the wrong layout again")
	}
	if out.Amount.IntPart() != 200 {
		t.Errorf("amount = %s, want the rounded gross 200", out.Amount)
	}
	fs := s.store.(*fakeStore)
	if fs.invoices[invID].SnapOrderRef == nil || *fs.invoices[invID].SnapOrderRef == "" {
		t.Error("provider ref not stamped before the charge — a lost response would strand the payment")
	}
}

func TestOrderQRIS_ReServesPendingQRWithoutNewCharge(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := &qrisStatusRT{t: t, key: "test-server-key", status: "pending"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	// The invoice already carries a live charge.
	ref := canonicalSnapRef("INV-T-1", o.ID)
	if err := s.store.SetInvoiceGatewayRef(context.Background(), nil, invID, ref, nil, nil); err != nil {
		t.Fatal(err)
	}

	out, err := s.OrderQRIS(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("OrderQRIS err = %v, want nil", err)
	}
	if out.PaymentStatus != "pending" {
		t.Fatalf("payment status = %q, want pending", out.PaymentStatus)
	}
	if !strings.Contains(out.QRURL, "txn-q1") {
		t.Errorf("qr url = %q, want the ORIGINAL charge's QR re-served from status", out.QRURL)
	}
	if st.charges != 0 {
		t.Errorf("charges = %d, want 0 — a pending transaction must never be re-charged", st.charges)
	}
}

func TestOrderQRIS_SettledAnswersSettledBooksNothing(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := &qrisStatusRT{t: t, key: "test-server-key", status: "settlement"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	ref := canonicalSnapRef("INV-T-1", o.ID)
	if err := s.store.SetInvoiceGatewayRef(context.Background(), nil, invID, ref, nil, nil); err != nil {
		t.Fatal(err)
	}

	out, err := s.OrderQRIS(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("OrderQRIS err = %v, want nil", err)
	}
	if out.PaymentStatus != "settled" {
		t.Fatalf("payment status = %q, want settled", out.PaymentStatus)
	}
	if st.charges != 0 {
		t.Errorf("charges = %d, want 0 on a settled transaction", st.charges)
	}
	// Settlement booking is the recovery poll's / the webhook's job, not the
	// charge-serve path's — nothing moves here (same division as ProcessPOS).
	noPaymentFor(t, s.store.(*fakeStore), invID)
}

func TestOrderQRIS_ExpiredRechargesOnSuffixedRef(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	st := &qrisStatusRT{t: t, key: "test-server-key", status: "expire"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	canonical := canonicalSnapRef("INV-T-1", o.ID)
	if err := s.store.SetInvoiceGatewayRef(context.Background(), nil, invID, canonical, nil, nil); err != nil {
		t.Fatal(err)
	}

	out, err := s.OrderQRIS(context.Background(), o.ID)
	if err != nil {
		t.Fatalf("OrderQRIS err = %v, want nil", err)
	}
	if out.PaymentStatus != "pending" || st.charges != 1 {
		t.Fatalf("out = %+v charges = %d, want a fresh charge after an expired QR", out, st.charges)
	}
	stored := s.store.(*fakeStore).invoices[invID].SnapOrderRef
	if stored == nil || !strings.HasPrefix(*stored, canonical+"-") {
		t.Errorf("stored ref = %v, want a %s-<unix> suffix (expired order_id is final at the provider)", stored, canonical)
	}
}

func TestOrderQRIS_GuardsMirrorSnapToken(t *testing.T) {
	s := svc()
	o, invID := snapOrderWithInvoice(t, s)
	// No gateway configured.
	if _, err := s.OrderQRIS(context.Background(), o.ID); !errors.Is(err, ErrGatewayDisabled) {
		t.Errorf("no-gateway err = %v, want ErrGatewayDisabled", err)
	}
	st := &qrisStatusRT{t: t, key: "test-server-key"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})
	// A fully paid order takes no second charge.
	fs := s.store.(*fakeStore)
	if _, err := fs.MarkInvoicePaid(context.Background(), nil, invID, o.TotalAmount); err != nil {
		t.Fatal(err)
	}
	if _, err := s.OrderQRIS(context.Background(), o.ID); !errors.Is(err, ErrInvalidStatus) {
		t.Errorf("paid-order err = %v, want ErrInvalidStatus (no open invoice)", err)
	}
	if st.charges != 0 {
		t.Errorf("charges = %d, want 0 behind the guards", st.charges)
	}
}

func TestOrderQRIS_BuyerOwnership(t *testing.T) {
	s := svc()
	o, _ := snapOrderWithInvoice(t, s)
	st := &qrisStatusRT{t: t, key: "test-server-key"}
	s.WithMidtrans(MidtransConfig{ServerKey: "test-server-key", CoreBaseURL: "http://example.test/v2"}).
		WithHTTPClient(&http.Client{Transport: st})

	// Non-staff buyer context: foreign user id 999 must return ErrNotFound
	ctxForeign := auth.ToContext(context.Background(), &auth.User{ID: 999, Role: auth.RoleBuyer}, 1)
	if _, err := s.OrderQRIS(ctxForeign, o.ID); !errors.Is(err, ErrNotFound) {
		t.Errorf("foreign buyer err = %v, want ErrNotFound", err)
	}

	// Owner buyer context: user 42 maps to customer 42 in snapOrderWithInvoice
	ctxOwner := auth.ToContext(context.Background(), &auth.User{ID: 42, Role: auth.RoleBuyer}, 1)
	res, err := s.OrderQRIS(ctxOwner, o.ID)
	if err != nil {
		t.Fatalf("owner buyer err = %v", err)
	}
	if res.QRURL == "" {
		t.Errorf("qr_url is empty")
	}
}
