diff --git a/internal/storage/billing.go b/internal/storage/billing.go index 329faef..f08e5a3 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -1758,9 +1758,18 @@ func timelineCreate(seed, action, message, objectType, objectID, customerID, che } } +// Timeline ids are deterministic seeds: the same billing event always derives the same id +// (for example a trial activation is keyed by subscription and period end). Re-recording one is +// therefore a no-op, not an error — the event is already in the ledger. +// +// Without this guard a repeated clock advance fails with a UNIQUE constraint error surfaced as a +// 500, and the failure is not recoverable: the clock has already moved, so every later advance +// retries the same event and collides again, wedging the fixture permanently. Test fixtures that +// reset subscriptions but keep the ledger hit this on their second run. func (s *SQLiteStore) insertTimeline(ctx context.Context, tx *sql.Tx, e billing.TimelineEntry) error { query := `INSERT INTO timeline_entries (id, action, message, object_type, object_id, customer_id, checkout_session_id, subscription_id, invoice_id, payment_intent_id, data, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING` args := []any{e.ID, e.Action, e.Message, e.ObjectType, e.ObjectID, e.CustomerID, e.CheckoutSessionID, e.SubscriptionID, e.InvoiceID, e.PaymentIntentID, encodeMap(e.Data), encodeTime(e.CreatedAt)} if tx != nil { _, err := tx.ExecContext(ctx, query, args...) diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 456ab76..ae3a025 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -252,3 +252,47 @@ func TestUpdateInvoicePaymentRejectsStaleOpenAttempt(t *testing.T) { t.Fatalf("current invoice = %#v, want paid invoice preserved", current) } } + +// A timeline id is a deterministic seed for a billing event, so the same event can be recorded +// twice — a repeated clock advance replays the activation for the same subscription and period. +// That must be a no-op. Before this guard it raised a UNIQUE constraint error that surfaced as a +// 500 and wedged the fixture: the clock had already moved, so every later advance collided again. +func TestRecordTimelineIsIdempotentForRepeatedEventIDs(t *testing.T) { + ctx := context.Background() + store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("OpenSQLite returned error: %v", err) + } + defer store.Close() + + entry := billing.TimelineEntry{ + ID: "tl_clock_trial_activate_sub_1_2030-01-15T00:00:00Z", + Object: billing.ObjectTimelineEntry, + Action: "customer.subscription.updated", + Message: "Trial subscription activated", + ObjectType: billing.ObjectSubscription, + ObjectID: "sub_1", + CreatedAt: time.Date(2030, 1, 15, 0, 0, 0, 0, time.UTC), + } + + if err := store.RecordTimeline(ctx, entry); err != nil { + t.Fatalf("first RecordTimeline returned error: %v", err) + } + if err := store.RecordTimeline(ctx, entry); err != nil { + t.Fatalf("repeated RecordTimeline returned error: %v", err) + } + + entries, err := store.Timeline(ctx, billing.TimelineFilter{}) + if err != nil { + t.Fatalf("Timeline returned error: %v", err) + } + matching := 0 + for _, got := range entries { + if got.ID == entry.ID { + matching++ + } + } + if matching != 1 { + t.Fatalf("timeline holds %d copies of %s, want exactly 1", matching, entry.ID) + } +}