Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion internal/storage/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
44 changes: 44 additions & 0 deletions internal/storage/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading