test: payments round-2 — webhook gate/M2 refund, gift-card cancel re-issue, till lock contention, sweep VAT rescue coverage
- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete - giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed - sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT - till: suffixed-key slot scan lock held across Square round-trip Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
@@ -119,22 +119,25 @@ func TestWebhook_AndSweep_DoNotDoubleComplete(t *testing.T) {
|
||||
t.Errorf("expected the sweep to leave the webhook-completed payment alone, got %q", got)
|
||||
}
|
||||
|
||||
// No split records / completion side effects may have been applied by the
|
||||
// sweep (the rescue is gated on the row still pending).
|
||||
// The webhook itself ran the payable-booking completion side-effects
|
||||
// (round-8 fix 2): the single £10 pending charge was re-split into a £5
|
||||
// deposit primary + £5 balance record, and the fully-paid booking was
|
||||
// completed. The sweep adds NOTHING on top — exactly one deposit + one
|
||||
// balance row, no duplicates.
|
||||
var recordCount int
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||
t.Fatalf("failed to count payment records: %v", err)
|
||||
}
|
||||
if recordCount != 1 {
|
||||
t.Errorf("expected exactly the one webhook-completed payment row (no sweep splits), got %d", recordCount)
|
||||
if recordCount != 2 {
|
||||
t.Errorf("expected the webhook-completed booking to have exactly 2 records (deposit + balance split), got %d", recordCount)
|
||||
}
|
||||
var bookingStatus string
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||
t.Fatalf("failed to query booking: %v", err)
|
||||
}
|
||||
if bookingStatus != "pending" {
|
||||
t.Errorf("expected the sweep not to complete the booking after the webhook settled the row, got %q", bookingStatus)
|
||||
if bookingStatus != "completed" {
|
||||
t.Errorf("expected the fully-paid webhook-completed booking to end 'completed', got %q", bookingStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,10 @@ func TestWebhook_DisputeStateUpdated_Open_KeepsOpen(t *testing.T) {
|
||||
func TestWebhook_PaymentUpdated_UpdatesPaymentStatus(t *testing.T) {
|
||||
const squarePaymentID = "sqp_updated_completed"
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
|
||||
// The webhook's booking gate (round-8) only completes booking-attached
|
||||
// rows — a booking-less row is a gift-card purchase and is left pending
|
||||
// (C6). Attach a payable booking so the gate completes the payment.
|
||||
attachWebhookTestBooking(t, payID, 10.00)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
@@ -872,6 +876,9 @@ func TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale(t *testing.T) {
|
||||
func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
|
||||
const squarePaymentID = "sqp_updated_idem"
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
|
||||
// The webhook's booking gate (round-8) only completes booking-attached
|
||||
// rows — attach a payable booking so the gate completes the payment.
|
||||
attachWebhookTestBooking(t, payID, 10.00)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
@@ -1016,10 +1023,15 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop verifies the orphan
|
||||
// detection is a no-op when no pending origin row matches: the event is
|
||||
// acknowledged 200 without touching any row or raising a notification.
|
||||
func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
|
||||
// TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Retries verifies the
|
||||
// round-8 fix 3 behavior: a COMPLETED payment whose square_payment_id matches
|
||||
// NO local row (payments or till_sales) AND NO pending origin row by
|
||||
// idempotency key/reference_id is a GENUINELY unknown charge. It is no longer
|
||||
// acked 200 — the handler returns 503 so Square re-delivers (its retry budget
|
||||
// bounds the retries) and writes NO dedup row, so the event can never be
|
||||
// dropped permanently. No critical notification is raised (there is no issue to
|
||||
// attribute, just an unresolved event).
|
||||
func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Retries(t *testing.T) {
|
||||
const orphanSquareID = "sqp_orphan_noorigin"
|
||||
before := countCriticalNotifications(t)
|
||||
|
||||
@@ -1041,14 +1053,14 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
|
||||
}`),
|
||||
}
|
||||
w := deliverWebhook(t, event)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 for a genuinely unknown COMPLETED payment (unresolved money event must be retried, not acked), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := countCriticalNotifications(t) - before; n != 0 {
|
||||
t.Errorf("expected no new critical notification with no origin match, got %d", n)
|
||||
}
|
||||
if got := countWebhookEvents(t, event.EventID); got != 1 {
|
||||
t.Errorf("expected 1 dedup row, got %d", got)
|
||||
if got := countWebhookEvents(t, event.EventID); got != 0 {
|
||||
t.Errorf("expected NO dedup row for the unresolved unknown payment (Square must retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1602,6 +1614,9 @@ func TestWebhook_RefundUpdated_DoesNotDemoteCompleted(t *testing.T) {
|
||||
func TestWebhook_EventTypeAliases_RouteToUpdatedHandlers(t *testing.T) {
|
||||
const sqPayID = "sqp_alias_pay"
|
||||
payID := createWebhookTestPayment(t, sqPayID, "pending")
|
||||
// The webhook's booking gate (round-8) only completes booking-attached
|
||||
// rows — attach a payable booking so the gate completes the payment.
|
||||
attachWebhookTestBooking(t, payID, 10.00)
|
||||
|
||||
payEvent := SquareWebhookEvent{
|
||||
Type: "payment.created",
|
||||
|
||||
@@ -423,10 +423,11 @@ func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) {
|
||||
func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) {
|
||||
// Well-formed payment.updated event carrying the full nested payment object
|
||||
// (data.object.payment with id/status) so handlePaymentUpdated can parse
|
||||
// and dispatch it — a known money event whose payload fails to parse
|
||||
// returns 503 without a dedup row (Square retries), which is NOT this
|
||||
// test's intent. The unique event_id and square_payment_id avoid colliding
|
||||
// with the other tests' dedup rows and payment fixtures.
|
||||
// and dispatch it. The COMPLETED charge matches NO local row — round-8 fix
|
||||
// 3: a genuinely unknown completed payment is retried (503, no dedup row)
|
||||
// rather than acked, so the signature passing is what this test proves (a
|
||||
// bad signature would 403 before dispatch). The unique event_id avoids
|
||||
// colliding with the other tests' dedup rows.
|
||||
body := []byte(fmt.Sprintf(`{"type":"payment.updated","event_id":"evt_envkey_1","created_at":%q,"data":{"object":{"payment":{"id":"sqp_env_key_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"updated_at":%q}}}}`, nowInRFC3339(0), nowInRFC3339(0)))
|
||||
key := "env-signing-key"
|
||||
notificationURL := "http://localhost:8080/webhooks/square"
|
||||
@@ -439,8 +440,8 @@ func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) {
|
||||
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", key)
|
||||
|
||||
w := makeWebhookRequest(body, sig, context.Background())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 with valid signature, got %d. body: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 for a signed COMPLETED payment.updated with no local row (unresolved unknown money event is retried, not acked), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +715,10 @@ func TestHandleSquareWebhook_ConcurrentSameEvent_Serialized(t *testing.T) {
|
||||
squarePaymentID := fmt.Sprintf("sqp_concurrent_same_%d", seq)
|
||||
eventID := fmt.Sprintf("evt_concurrent_same_%d", seq)
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
|
||||
// The webhook's booking gate (round-8) only completes booking-attached
|
||||
// rows — a booking-less row is a gift-card purchase and is left pending
|
||||
// (C6). Attach a payable booking so the gate completes the payment.
|
||||
attachWebhookTestBooking(t, payID, 10.00)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
@@ -791,6 +796,9 @@ func TestHandleSquareWebhook_DedupCacheEviction_Redispatches(t *testing.T) {
|
||||
|
||||
const squarePaymentID = "sqp_dedup_eviction"
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
|
||||
// The webhook's booking gate (round-8) only completes booking-attached
|
||||
// rows — attach a payable booking so the gate completes the payment.
|
||||
attachWebhookTestBooking(t, payID, 10.00)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
|
||||
Reference in New Issue
Block a user