//go:build test && dev package payments import ( "context" "testing" "crussell/internal/square" ) // TestDeriveRefundIdempotencyKey_Deterministic locks the M1 property: the // server-side refund key derived from (payment_id, amount pence, refund type) // is DETERMINISTIC — a retry of the same logical refund re-derives the SAME // key, so Square's idempotency dedup returns the original refund instead of // minting a second one. func TestDeriveRefundIdempotencyKey_Deterministic(t *testing.T) { key1 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual") key2 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual") if key1 != key2 { t.Errorf("expected the derived refund key to be deterministic, got %q vs %q", key1, key2) } } // TestDeriveRefundIdempotencyKey_DistinguishesInputs locks the M1 discriminator // fields: the key MUST change when the payment, the amount, or the refund type // changes (a distinct partial refund of the same amount must never collide with // a cancellation refund of the same size). func TestDeriveRefundIdempotencyKey_DistinguishesInputs(t *testing.T) { base := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual") otherPayment := deriveRefundIdempotencyKey("pay9999999999", 5000, "manual") if otherPayment == base { t.Errorf("expected a different payment id to produce a different refund key") } otherAmount := deriveRefundIdempotencyKey("pay1234567890", 5001, "manual") if otherAmount == base { t.Errorf("expected a different amount to produce a different refund key") } otherType := deriveRefundIdempotencyKey("pay1234567890", 5000, "cancellation") if otherType == base { t.Errorf("expected a different refund type to produce a different refund key") } } // TestDeriveRefundIdempotencyKey_RespectsSquareLimit locks the // truncateIdempotencyKey semantics: every derived key stays within Square's // 45-char /v2/refunds idempotency-key limit, even for an over-length candidate // (a long payment id / large amount), and the truncation stays deterministic. func TestDeriveRefundIdempotencyKey_RespectsSquareLimit(t *testing.T) { cases := []struct { paymentID string amount int64 refundType string }{ {"pay1234567890", 5000, "manual"}, {"pay1234567890", 5000, "cancellation"}, {"a-very-long-payment-id-that-exceeds-the-45-char-limit-when-combined", 999999999, "manual"}, {"pay1234567890", 999999999, "a-refund-type-that-is-itself-quite-long"}, } for _, c := range cases { key := deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) if len(key) > maxIdempotencyKeyLength { t.Errorf("derived refund key %q (%d chars) exceeds Square's %d-char limit", key, len(key), maxIdempotencyKeyLength) } if key != deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) { t.Errorf("derived refund key %q is not deterministic across calls", key) } } } // TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund locks // the M1 end-to-end dedup: a refund issued with the deterministic server-side // key, resolved by a sweep pass, and then RETRIED maps to the SAME Square refund // (the mock's refundByKey dedup returns the original) — a second Square refund // is never minted. This is the mechanism the refund issuance uses so a // no-client-key retry after sweep resolution dedups onto the first refund. func TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund(t *testing.T) { mock := square.NewDevClient().(*square.MockClient) payment, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "seed-refund-dedup-payment", }) if err != nil { t.Fatalf("failed to seed the payment at the mock: %v", err) } paymentID := "payrefund000001" amountPence := int64(5000) // The no-client-key refund issues with the deterministic server-side key. key := deriveRefundIdempotencyKey(paymentID, amountPence, "manual") first, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ PaymentID: payment.SquarePayID, Amount: amountPence, IdempotencyKey: key, Reason: "test", }) if err != nil { t.Fatalf("first refund failed: %v", err) } // After the sweep resolves the first attempt, the client retries the SAME // logical refund. The retry re-derives the SAME deterministic key, so the // mock's Square-style dedup returns the ORIGINAL refund — RefundKeyCount // stays 1 and no second Square refund is minted. retry, retryErr := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ PaymentID: payment.SquarePayID, Amount: amountPence, IdempotencyKey: deriveRefundIdempotencyKey(paymentID, amountPence, "manual"), Reason: "test", }) if retryErr != nil { t.Fatalf("retry after sweep resolution failed: %v", retryErr) } if first.ID != retry.ID { t.Errorf("expected the retry to dedup onto the original refund %s, got a different refund %s", first.ID, retry.ID) } if got := mock.RefundKeyCount(); got != 1 { t.Errorf("expected exactly ONE Square refund after the retry, got %d distinct refund keys", got) } }