From 0991ad2b3ce057263376e8343cc0ac3b2ed5e033 Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Mon, 31 Aug 2026 20:16:03 +0000 Subject: [PATCH 1/2] feat(v4): add ordered random amount allocator --- internal/v4/payments/allocator.go | 140 +++++++++++++ internal/v4/payments/allocator_test.go | 272 +++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 internal/v4/payments/allocator.go create mode 100644 internal/v4/payments/allocator_test.go diff --git a/internal/v4/payments/allocator.go b/internal/v4/payments/allocator.go new file mode 100644 index 0000000..35ed8c7 --- /dev/null +++ b/internal/v4/payments/allocator.go @@ -0,0 +1,140 @@ +package payments + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "time" + + "github.com/Phloraxx/payment-api/internal/v4/storage" +) + +var ErrPaymentCapacity = errors.New("payment capacity temporarily unavailable") + +type RandomIndex func(max int) (int, error) + +type Allocator struct { + Random RandomIndex + SoftHorizon time.Duration + Buckets int +} + +func NewAllocator() Allocator { + return Allocator{ + Random: cryptoRandomIndex, + SoftHorizon: 4 * time.Hour, + Buckets: 2, + } +} + +func (a Allocator) Select(ctx context.Context, tx *storage.ImmediateTx, profileID string, requestedAmountPaise int64, now time.Time) (int64, error) { + if tx == nil { + return 0, errors.New("sqlite connection is required") + } + if profileID == "" { + return 0, errors.New("collection profile is required") + } + if requestedAmountPaise <= 0 || requestedAmountPaise%100 != 0 { + return 0, errors.New("requested amount must be positive whole INR") + } + buckets := a.Buckets + if buckets <= 0 { + buckets = 2 + } + randomIndex := a.Random + if randomIndex == nil { + randomIndex = cryptoRandomIndex + } + + nowMS := now.UTC().UnixMilli() + if _, err := tx.ExecContext(ctx, `UPDATE amount_reservations + SET released_at = ? + WHERE released_at IS NULL AND reserved_until <= ?`, nowMS, nowMS); err != nil { + return 0, fmt.Errorf("release due amount reservations: %w", err) + } + + cutoffMS := now.Add(-a.SoftHorizon).UTC().UnixMilli() + for bucket := 0; bucket < buckets; bucket++ { + start := requestedAmountPaise + int64(bucket*100) + 1 + end := requestedAmountPaise + int64(bucket*100) + 99 + candidates, err := loadBucketCandidates(ctx, tx, profileID, start, end, cutoffMS) + if err != nil { + return 0, err + } + if len(candidates) == 0 { + continue + } + index, err := randomIndex(len(candidates)) + if err != nil { + return 0, fmt.Errorf("choose payable amount: %w", err) + } + if index < 0 || index >= len(candidates) { + return 0, fmt.Errorf("random index %d out of range for %d candidates", index, len(candidates)) + } + return candidates[index], nil + } + return 0, ErrPaymentCapacity +} + +func loadBucketCandidates(ctx context.Context, tx *storage.ImmediateTx, profileID string, start, end, softCutoffMS int64) ([]int64, error) { + rows, err := tx.QueryContext(ctx, ` +SELECT payable_amount_paise, + MAX(CASE WHEN released_at IS NULL THEN 1 ELSE 0 END) AS active, + MAX(last_used_at) AS last_used_at +FROM amount_reservations +WHERE collection_profile_id = ? + AND payable_amount_paise BETWEEN ? AND ? +GROUP BY payable_amount_paise`, profileID, start, end) + if err != nil { + return nil, fmt.Errorf("query amount bucket: %w", err) + } + defer rows.Close() + + type use struct { + active bool + lastUsed int64 + } + used := make(map[int64]use, 99) + for rows.Next() { + var amount, lastUsed int64 + var active int + if err := rows.Scan(&amount, &active, &lastUsed); err != nil { + return nil, fmt.Errorf("scan amount bucket: %w", err) + } + used[amount] = use{active: active == 1, lastUsed: lastUsed} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate amount bucket: %w", err) + } + + preferred := make([]int64, 0, 99) + recent := make([]int64, 0, 99) + for amount := start; amount <= end; amount++ { + state, seen := used[amount] + if seen && state.active { + continue + } + if !seen || state.lastUsed < softCutoffMS { + preferred = append(preferred, amount) + } else { + recent = append(recent, amount) + } + } + if len(preferred) > 0 { + return preferred, nil + } + return recent, nil +} + +func cryptoRandomIndex(max int) (int, error) { + if max <= 0 { + return 0, errors.New("random range must be positive") + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + if err != nil { + return 0, err + } + return int(n.Int64()), nil +} diff --git a/internal/v4/payments/allocator_test.go b/internal/v4/payments/allocator_test.go new file mode 100644 index 0000000..ecf952e --- /dev/null +++ b/internal/v4/payments/allocator_test.go @@ -0,0 +1,272 @@ +package payments + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/v4/storage" +) + +func openAllocatorDB(t *testing.T) *storage.DB { + t.Helper() + db, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "paygate.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + now := int64(1_788_200_000_000) + if _, err := db.SQL.Exec(`INSERT INTO collection_profiles(id,label,upi_id,parser,enabled,active,created_at,updated_at) + VALUES('paytm','Paytm','merchant@paytm','paytm_notification',1,1,?,?)`, now, now); err != nil { + t.Fatal(err) + } + return db +} + +func fixedIndex(index int) RandomIndex { + return func(max int) (int, error) { + if index >= max { + return 0, fmt.Errorf("fixed index %d >= %d", index, max) + } + return index, nil + } +} + +func TestAllocatorRandomizesInsideBaseBucket(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + allocator := NewAllocator() + allocator.Random = fixedIndex(36) + + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10037 { + t.Fatalf("got %d, want 10037", got) + } +} + +func TestAllocatorNeverOverflowsWhileBaseBucketHasOneFreeValue(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + fillActiveRange(t, db.SQL, 10001, 10099, 10077, now) + + allocator := NewAllocator() + allocator.Random = fixedIndex(0) + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10077 { + t.Fatalf("got %d, want the only free base-bucket value 10077", got) + } +} + +func TestAllocatorUsesOverflowOnlyAfterBaseBucketExhausted(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + fillActiveRange(t, db.SQL, 10001, 10099, -1, now) + + allocator := NewAllocator() + allocator.Random = fixedIndex(36) + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10137 { + t.Fatalf("got %d, want 10137 from overflow bucket", got) + } +} + +func TestSoftRecentUseCannotForceEarlyOverflow(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + + // Create released recent history for every base-bucket value. The whole base + // bucket is still free, so soft avoidance must fall back to these values + // instead of moving to ₹101.xx. + fillReleasedRange(t, db.SQL, 10001, 10099, now.Add(-time.Minute)) + + allocator := NewAllocator() + allocator.SoftHorizon = 4 * time.Hour + allocator.Random = fixedIndex(98) + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10099 { + t.Fatalf("got %d, want base-bucket value 10099", got) + } +} + +func TestAllocatorPrefersNotRecentlyUsedValueWithinSameBucket(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + + // Make every base value active except 10037 and 10048. 10037 was just + // released; 10048 has no history. Preferred pool must contain only 10048. + fillActiveRange(t, db.SQL, 10001, 10099, 10037, now) + releaseActive(t, db.SQL, 10048) // make 10048 free with old history + insertReleasedHistory(t, db.SQL, 10037, now.Add(-time.Minute), "recent_10037") + if _, err := db.SQL.Exec(`UPDATE amount_reservations SET last_used_at=? WHERE payable_amount_paise=10048`, now.Add(-24*time.Hour).UnixMilli()); err != nil { + t.Fatal(err) + } + + allocator := NewAllocator() + allocator.SoftHorizon = 4 * time.Hour + allocator.Random = fixedIndex(0) + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10048 { + t.Fatalf("got %d, want older free amount 10048", got) + } +} + +func TestAllocatorReleasesDueReservationBeforeChoosing(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + fillActiveRange(t, db.SQL, 10001, 10099, -1, now) + if _, err := db.SQL.Exec(`UPDATE amount_reservations SET reserved_at=?, reserved_until=?, last_used_at=? WHERE payable_amount_paise=10037`, now.Add(-20*time.Minute).UnixMilli(), now.Add(-time.Second).UnixMilli(), now.Add(-20*time.Minute).UnixMilli()); err != nil { + t.Fatal(err) + } + + allocator := NewAllocator() + allocator.SoftHorizon = 4 * time.Hour + allocator.Random = fixedIndex(0) + var got int64 + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var err error + got, err = allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if err != nil { + t.Fatal(err) + } + if got != 10037 { + t.Fatalf("got %d, want newly released 10037 without premature overflow", got) + } +} + +func TestAllocatorReturnsCapacityOnlyWhenBothBucketsFull(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + now := time.UnixMilli(1_788_200_000_000) + fillActiveRange(t, db.SQL, 10001, 10099, -1, now) + fillActiveRange(t, db.SQL, 10101, 10199, -1, now) + + allocator := NewAllocator() + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + _, err := allocator.Select(ctx, tx, "paytm", 10000, now) + return err + }) + if !errors.Is(err, ErrPaymentCapacity) { + t.Fatalf("error = %v, want ErrPaymentCapacity", err) + } +} + +func TestAllocatorRejectsNonWholeRequestedAmount(t *testing.T) { + db := openAllocatorDB(t) + ctx := context.Background() + allocator := NewAllocator() + err := db.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + _, err := allocator.Select(ctx, tx, "paytm", 10001, time.Now()) + return err + }) + if err == nil { + t.Fatal("expected non-whole requested amount to fail") + } +} + +func fillActiveRange(t *testing.T, db *sql.DB, start, end, leaveFree int64, now time.Time) { + t.Helper() + for amount := start; amount <= end; amount++ { + if amount == leaveFree { + continue + } + id := fmt.Sprintf("p_%d", amount) + insertTestPayment(t, db, id, amount, now) + if _, err := db.Exec(`INSERT INTO amount_reservations(id,collection_profile_id,payable_amount_paise,payment_id,reserved_at,reserved_until,last_used_at) + VALUES(?,?,?,?,?,?,?)`, "r_"+id, "paytm", amount, id, now.UnixMilli(), now.Add(15*time.Minute).UnixMilli(), now.UnixMilli()); err != nil { + t.Fatal(err) + } + } +} + +func fillReleasedRange(t *testing.T, db *sql.DB, start, end int64, lastUsed time.Time) { + t.Helper() + for amount := start; amount <= end; amount++ { + insertReleasedHistory(t, db, amount, lastUsed, fmt.Sprintf("hist_%d", amount)) + } +} + +func insertReleasedHistory(t *testing.T, db *sql.DB, amount int64, lastUsed time.Time, suffix string) { + t.Helper() + id := "p_" + suffix + insertTestPayment(t, db, id, amount, lastUsed.Add(-time.Hour)) + reservedAt := lastUsed.Add(-20 * time.Minute).UnixMilli() + releasedAt := lastUsed.Add(-5 * time.Minute).UnixMilli() + if _, err := db.Exec(`INSERT INTO amount_reservations(id,collection_profile_id,payable_amount_paise,payment_id,reserved_at,reserved_until,released_at,last_used_at) + VALUES(?,?,?,?,?,?,?,?)`, "r_"+suffix, "paytm", amount, id, reservedAt, releasedAt, releasedAt, lastUsed.UnixMilli()); err != nil { + t.Fatal(err) + } +} + +func releaseActive(t *testing.T, db *sql.DB, amount int64) { + t.Helper() + if _, err := db.Exec(`UPDATE amount_reservations SET released_at=reserved_until WHERE payable_amount_paise=?`, amount); err != nil { + t.Fatal(err) + } +} + +func insertTestPayment(t *testing.T, db *sql.DB, id string, payable int64, created time.Time) { + t.Helper() + requested := int64(10000) + adjustment := payable - requested + if adjustment <= 0 || adjustment > 199 { + t.Fatalf("invalid test payable %d", payable) + } + now := created.UnixMilli() + if _, err := db.Exec(`INSERT INTO payments(id,name,external_id,requested_amount_paise,payable_amount_paise,adjustment_paise,collection_profile_id,upi_id_snapshot,status,created_at,expires_at,grace_until,reuse_after) + VALUES(?,?,?,?,?,?,'paytm','merchant@paytm','pending',?,?,?,?)`, id, "Person", "evt_1", requested, payable, adjustment, + now, now+300_000, now+600_000, now+900_000); err != nil { + t.Fatal(err) + } +} From e54d09ae08d4e543ad05101c3dc27915df41ee3a Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Tue, 1 Sep 2026 03:45:08 +0000 Subject: [PATCH 2/2] feat(v4): manage collection profiles atomically --- internal/v4/profiles/service.go | 231 +++++++++++++++++++++++++++ internal/v4/profiles/service_test.go | 141 ++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/v4/profiles/service.go create mode 100644 internal/v4/profiles/service_test.go diff --git a/internal/v4/profiles/service.go b/internal/v4/profiles/service.go new file mode 100644 index 0000000..2b1fd95 --- /dev/null +++ b/internal/v4/profiles/service.go @@ -0,0 +1,231 @@ +package profiles + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/Phloraxx/payment-api/internal/v4/storage" +) + +var ( + ErrProfileNotFound = errors.New("collection profile not found") + ErrProfileDisabled = errors.New("collection profile is disabled") + ErrCannotDisableActiveProfile = errors.New("cannot disable active collection profile") + ErrInvalidProfile = errors.New("invalid collection profile") +) + +type Service struct { + DB *storage.DB + Now func() time.Time +} +type Profile struct { + ID string + Label string + UPIID string + PayeeName string + Parser string + Enabled bool + Active bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type UpsertInput struct { + ID string + Label string + UPIID string + PayeeName string + Parser string + Enabled bool +} + +func NewService(db *storage.DB) *Service { + return &Service{DB: db, Now: time.Now} +} + +func (s *Service) Upsert(ctx context.Context, in UpsertInput) (Profile, error) { + if s == nil || s.DB == nil || s.DB.SQL == nil { + return Profile{}, errors.New("profile storage is required") + } + in.ID = strings.ToLower(strings.TrimSpace(in.ID)) + in.Label = strings.TrimSpace(in.Label) + in.UPIID = strings.TrimSpace(in.UPIID) + in.PayeeName = strings.TrimSpace(in.PayeeName) + in.Parser = strings.TrimSpace(in.Parser) + if err := validateUpsert(in); err != nil { + return Profile{}, err + } + nowFn := s.Now + if nowFn == nil { + nowFn = time.Now + } + now := nowFn().UTC() + var out Profile + err := s.DB.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + var active int + err := tx.QueryRowContext(ctx, `SELECT active FROM collection_profiles WHERE id=?`, in.ID).Scan(&active) + switch { + case errors.Is(err, sql.ErrNoRows): + _, err = tx.ExecContext(ctx, `INSERT INTO collection_profiles(id,label,upi_id,payee_name,parser,enabled,active,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,?,?)`, in.ID, in.Label, in.UPIID, nullable(in.PayeeName), in.Parser, boolInt(in.Enabled), now.UnixMilli(), now.UnixMilli()) + case err != nil: + return fmt.Errorf("read collection profile: %w", err) + case active == 1 && !in.Enabled: + return ErrCannotDisableActiveProfile + default: + _, err = tx.ExecContext(ctx, `UPDATE collection_profiles + SET label=?,upi_id=?,payee_name=?,parser=?,enabled=?,updated_at=? WHERE id=?`, + in.Label, in.UPIID, nullable(in.PayeeName), in.Parser, boolInt(in.Enabled), now.UnixMilli(), in.ID) + } + if err != nil { + return fmt.Errorf("save collection profile: %w", err) + } + profile, err := getWith(ctx, tx, in.ID) + if err != nil { + return err + } + out = profile + return nil + }) + return out, err +} + +func (s *Service) Activate(ctx context.Context, id string) (Profile, error) { + if s == nil || s.DB == nil || s.DB.SQL == nil { + return Profile{}, errors.New("profile storage is required") + } + id = strings.ToLower(strings.TrimSpace(id)) + nowFn := s.Now + if nowFn == nil { + nowFn = time.Now + } + now := nowFn().UTC() + var out Profile + err := s.DB.WithImmediateTx(ctx, func(tx *storage.ImmediateTx) error { + profile, err := getWith(ctx, tx, id) + if err != nil { + return err + } + if !profile.Enabled { + return ErrProfileDisabled + } + if profile.Active { + out = profile + return nil + } + if _, err := tx.ExecContext(ctx, `UPDATE collection_profiles SET active=0, updated_at=? WHERE active=1`, now.UnixMilli()); err != nil { + return fmt.Errorf("deactivate collection profile: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE collection_profiles SET active=1, updated_at=? WHERE id=?`, now.UnixMilli(), id); err != nil { + return fmt.Errorf("activate collection profile: %w", err) + } + out, err = getWith(ctx, tx, id) + return err + }) + return out, err +} + +func (s *Service) Get(ctx context.Context, id string) (Profile, error) { + if s == nil || s.DB == nil || s.DB.SQL == nil { + return Profile{}, errors.New("profile storage is required") + } + return getWith(ctx, s.DB.SQL, strings.ToLower(strings.TrimSpace(id))) +} +func (s *Service) List(ctx context.Context) ([]Profile, error) { + if s == nil || s.DB == nil || s.DB.SQL == nil { + return nil, errors.New("profile storage is required") + } + rows, err := s.DB.SQL.QueryContext(ctx, `SELECT id,label,upi_id,COALESCE(payee_name,''),parser,enabled,active,created_at,updated_at + FROM collection_profiles ORDER BY active DESC, label COLLATE NOCASE, id`) + if err != nil { + return nil, fmt.Errorf("list collection profiles: %w", err) + } + defer rows.Close() + var out []Profile + for rows.Next() { + profile, err := scanProfile(rows) + if err != nil { + return nil, err + } + out = append(out, profile) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate collection profiles: %w", err) + } + return out, nil +} + +type queryRower interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type scanner interface { + Scan(...any) error +} + +func getWith(ctx context.Context, q queryRower, id string) (Profile, error) { + row := q.QueryRowContext(ctx, `SELECT id,label,upi_id,COALESCE(payee_name,''),parser,enabled,active,created_at,updated_at + FROM collection_profiles WHERE id=?`, id) + profile, err := scanProfile(row) + if errors.Is(err, sql.ErrNoRows) { + return Profile{}, ErrProfileNotFound + } + if err != nil { + return Profile{}, fmt.Errorf("read collection profile: %w", err) + } + return profile, nil +} + +func scanProfile(row scanner) (Profile, error) { + var p Profile + var enabled, active int + var created, updated int64 + if err := row.Scan(&p.ID, &p.Label, &p.UPIID, &p.PayeeName, &p.Parser, &enabled, &active, &created, &updated); err != nil { + return Profile{}, err + } + p.Enabled = enabled == 1 + p.Active = active == 1 + p.CreatedAt = time.UnixMilli(created).UTC() + p.UpdatedAt = time.UnixMilli(updated).UTC() + return p, nil +} + +func validateUpsert(in UpsertInput) error { + if in.ID == "" || len(in.ID) > 64 || strings.ContainsAny(in.ID, " \t\r\n") { + return fmt.Errorf("%w: id must contain 1-64 non-space characters", ErrInvalidProfile) + } + if in.Label == "" || utf8.RuneCountInString(in.Label) > 120 { + return fmt.Errorf("%w: label must contain 1-120 characters", ErrInvalidProfile) + } + if len(in.UPIID) < 3 || len(in.UPIID) > 255 || !strings.Contains(in.UPIID, "@") { + return fmt.Errorf("%w: upi_id must be a valid VPA-like identifier", ErrInvalidProfile) + } + if utf8.RuneCountInString(in.PayeeName) > 120 { + return fmt.Errorf("%w: payee_name is too long", ErrInvalidProfile) + } + switch in.Parser { + case "paytm_notification", "kotak_sms": + default: + return fmt.Errorf("%w: unsupported parser %q", ErrInvalidProfile, in.Parser) + } + return nil +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func nullable(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/internal/v4/profiles/service_test.go b/internal/v4/profiles/service_test.go new file mode 100644 index 0000000..10eb837 --- /dev/null +++ b/internal/v4/profiles/service_test.go @@ -0,0 +1,141 @@ +package profiles + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/v4/storage" +) + +func openTestDB(t *testing.T) *storage.DB { + t.Helper() + db, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "paygate.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func testProfile(id string, enabled bool) UpsertInput { + parser := "paytm_notification" + if id == "kotak" { + parser = "kotak_sms" + } + return UpsertInput{ID: id, Label: id, UPIID: id + "@upi", PayeeName: "PayGate", Parser: parser, Enabled: enabled} +} +func TestActivateSwitchesAtomically(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + svc := NewService(db) + svc.Now = func() time.Time { return time.UnixMilli(1_788_200_000_000).UTC() } + + if _, err := svc.Upsert(ctx, testProfile("paytm", true)); err != nil { + t.Fatal(err) + } + if _, err := svc.Upsert(ctx, testProfile("kotak", true)); err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, "paytm"); err != nil { + t.Fatal(err) + } + got, err := svc.Activate(ctx, "kotak") + if err != nil { + t.Fatal(err) + } + if !got.Active || got.ID != "kotak" { + t.Fatalf("active profile = %+v", got) + } + + profiles, err := svc.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(profiles) != 2 || profiles[0].ID != "kotak" || !profiles[0].Active || profiles[1].Active { + t.Fatalf("profiles after switch = %+v", profiles) + } +} +func TestDisabledProfileCannotBeActivatedOrDisableCurrentActive(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + svc := NewService(db) + + if _, err := svc.Upsert(ctx, testProfile("paytm", true)); err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, "paytm"); err != nil { + t.Fatal(err) + } + if _, err := svc.Upsert(ctx, testProfile("paytm", false)); !errors.Is(err, ErrCannotDisableActiveProfile) { + t.Fatalf("disable active error = %v", err) + } + current, err := svc.Get(ctx, "paytm") + if err != nil { + t.Fatal(err) + } + if !current.Enabled || !current.Active { + t.Fatalf("active profile changed after rejected disable: %+v", current) + } + + if _, err := svc.Upsert(ctx, testProfile("kotak", false)); err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, "kotak"); !errors.Is(err, ErrProfileDisabled) { + t.Fatalf("activate disabled error = %v", err) + } +} +func TestProfileSwitchDoesNotMutateExistingPaymentSnapshot(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + svc := NewService(db) + now := int64(1_788_200_000_000) + + if _, err := svc.Upsert(ctx, testProfile("paytm", true)); err != nil { + t.Fatal(err) + } + if _, err := svc.Upsert(ctx, testProfile("kotak", true)); err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, "paytm"); err != nil { + t.Fatal(err) + } + if _, err := db.SQL.Exec(`INSERT INTO payments(id,name,external_id,requested_amount_paise,payable_amount_paise,adjustment_paise,collection_profile_id,upi_id_snapshot,payee_name_snapshot,status,created_at,expires_at,grace_until,reuse_after) + VALUES('pay_1','Sourav','evt_1',10000,10037,37,'paytm','paytm@upi','PayGate','pending',?,?,?,?)`, now, now+300_000, now+600_000, now+900_000); err != nil { + t.Fatal(err) + } + if _, err := svc.Activate(ctx, "kotak"); err != nil { + t.Fatal(err) + } + var profileID, upiID string + if err := db.SQL.QueryRow(`SELECT collection_profile_id,upi_id_snapshot FROM payments WHERE id='pay_1'`).Scan(&profileID, &upiID); err != nil { + t.Fatal(err) + } + if profileID != "paytm" || upiID != "paytm@upi" { + t.Fatalf("payment snapshot changed: profile=%q upi=%q", profileID, upiID) + } +} +func TestProfileValidationAndNotFound(t *testing.T) { + ctx := context.Background() + db := openTestDB(t) + svc := NewService(db) + + badUPI := testProfile("paytm", true) + badUPI.UPIID = "not-a-vpa" + if _, err := svc.Upsert(ctx, badUPI); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("bad upi error = %v", err) + } + badParser := testProfile("paytm", true) + badParser.Parser = "gpay" + if _, err := svc.Upsert(ctx, badParser); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("bad parser error = %v", err) + } + if _, err := svc.Get(ctx, "missing"); !errors.Is(err, ErrProfileNotFound) { + t.Fatalf("missing get error = %v", err) + } + if _, err := svc.Activate(ctx, "missing"); !errors.Is(err, ErrProfileNotFound) { + t.Fatalf("missing activate error = %v", err) + } +}