From a5adbf347b2a12953bffebee8b62db0903fe5862 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 23:45:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/pay):=20=E8=AE=A2=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=AB=AF=E7=82=B9=20+=20=E5=88=86=E5=B8=81=E7=A7=8D?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=BB=B7=20+=20=E5=AF=8C=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /v1/pay/orders 订单列表(Store.ListByUser,本地台账倒序) - catalog 增 PriceUsdtMicro(USDT 一口价展示) + SettlementCurrency/DisplayAmountMinor - 下单按支付方式落展示金额/结算币种(crypto→USDT / 法币→CNY),webhook 覆盖实际 - GetOrder 详情回带 sku/plan/method/amount/currency/created_at/paid_at - 新增 List 越权/倒序/富字段 + 分币种展示金额 + 详情富字段测试 - 修 3 个 pre-existing 迁移测试(renumber 后 21→23 遗留) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- server/cmd/server/main.go | 1 + server/internal/pay/catalog.go | 42 +++++-- server/internal/pay/handler.go | 65 +++++++++- server/internal/pay/handler_test.go | 117 +++++++++++++++++- server/internal/pay/store.go | 42 +++++-- server/internal/pay/webhook_sqlite_test.go | 8 +- .../store/pay_migration_rehearsal_test.go | 46 +++---- server/internal/store/sqlite_migrate_test.go | 4 +- 8 files changed, 273 insertions(+), 52 deletions(-) diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d91f3cf..ec94923 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -452,6 +452,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv protected.Get("/notices", accountAPI.ListNotices) if payHandler != nil { protected.Get("/pay/catalog", payHandler.Catalog) + protected.Get("/pay/orders", payHandler.List) protected.Post("/pay/orders", payHandler.CreateOrder) protected.Get("/pay/orders/{orderNo}", payHandler.GetOrder) protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry) diff --git a/server/internal/pay/catalog.go b/server/internal/pay/catalog.go index 75823f2..73d9b69 100644 --- a/server/internal/pay/catalog.go +++ b/server/internal/pay/catalog.go @@ -3,21 +3,25 @@ package pay import "github.com/wangjia/pangolin/server/internal/codes" // CatalogItem 是可购档位。SKU 与 pay 侧 products.biz_code 一一对应 -// (webhook product_biz_code 原样回带);PriceMinor 仅展示(CNY 分), -// 实际扣款以 pay 侧 ProductPrice/price 为准——一致性列入联调 checklist。 +// (webhook product_biz_code 原样回带);价格字段**仅展示**(实际扣款以 pay 侧 +// per-currency ProductPrice 为准——一致性列入联调 checklist): +// - PriceMinor:CNY 分(支付宝/哪吒等法币渠道) +// - PriceUsdtMicro:USDT 微元(6 位精度,加密货币渠道) type CatalogItem struct { - SKU string `json:"sku"` - Plan string `json:"plan"` - Days int `json:"days"` - PriceMinor int64 `json:"price_minor"` - Currency string `json:"currency"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Days int `json:"days"` + PriceMinor int64 `json:"price_minor"` // CNY 分(展示) + Currency string `json:"currency"` // CNY + PriceUsdtMicro int64 `json:"price_usdt_micro"` // USDT 微元(展示;各币种一口价、非汇率换算) } // Catalog 三档单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。 +// USDT 展示价与 pay 侧 products 一口价对齐($4.99/$12.99/$34.99),不做实时汇率换算。 var Catalog = []CatalogItem{ - {SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY"}, - {SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY"}, - {SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY"}, + {SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY", PriceUsdtMicro: 4990000}, + {SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY", PriceUsdtMicro: 12990000}, + {SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY", PriceUsdtMicro: 34990000}, } func CatalogBySKU(sku string) (CatalogItem, bool) { @@ -28,3 +32,21 @@ func CatalogBySKU(sku string) (CatalogItem, bool) { } return CatalogItem{}, false } + +// SettlementCurrency 按支付方式给出结算币种(展示/落台账用;实际以 pay 侧为准)。 +// crypto→USDT;其余法币渠道(alipay/nezha/…)→CNY。 +func SettlementCurrency(method string) string { + if method == "crypto" { + return "USDT" + } + return "CNY" +} + +// DisplayAmountMinor 按支付方式给出该档「展示金额」(下单时预估、落台账, +// webhook 到账后由实际结算金额覆盖)。crypto→USDT 微元;法币→CNY 分。 +func DisplayAmountMinor(it CatalogItem, method string) int64 { + if method == "crypto" { + return it.PriceUsdtMicro + } + return it.PriceMinor +} diff --git a/server/internal/pay/handler.go b/server/internal/pay/handler.go index aba7369..839c3b9 100644 --- a/server/internal/pay/handler.go +++ b/server/internal/pay/handler.go @@ -109,7 +109,8 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } - if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" { + item, ok := CatalogBySKU(req.SKU) + if !ok || req.Method == "" { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } @@ -129,17 +130,73 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { writePayErr(w, err) return } - if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil { + // 下单即落展示金额/结算币种(按支付方式);webhook 到账后由实际结算金额覆盖。 + amount := DisplayAmountMinor(item, req.Method) + currency := SettlementCurrency(req.Method) + if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method, amount, currency); err != nil { // 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。 slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err) } writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session}) } +// ─── GET /v1/pay/orders (列表) ────────────────────────────────────────────── + +// orderView 是订单的台账视图(列表/详情共用富字段;金额为结算币种 minor:CNY 分 / USDT 微元)。 +type orderView struct { + OrderNo string `json:"order_no"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Days int `json:"days"` + Method string `json:"method"` + Status string `json:"status"` // 本地台账:created | paid | canceled + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Channel string `json:"channel,omitempty"` + CreatedAt string `json:"created_at"` // RFC3339 + PaidAt *string `json:"paid_at,omitempty"` +} + +func orderViewFromRow(row *PurchaseRow) orderView { + v := orderView{ + OrderNo: row.OutTradeNo, SKU: row.SKU, Method: row.Method, Status: row.Status, + AmountMinor: row.AmountMinor, Currency: row.Currency, Channel: row.Channel, + CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), + } + if it, ok := CatalogBySKU(row.SKU); ok { + v.Plan, v.Days = it.Plan, it.Days + } + if row.PaidAt.Valid { + s := row.PaidAt.Time.UTC().Format(time.RFC3339) + v.PaidAt = &s + } + return v +} + +// List 列出当前用户历史订单(倒序;纯本地台账,不逐单回查 pay 上游)。 +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + rows, err := h.store.ListByUser(ctx, uid, 100) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + out := make([]orderView, 0, len(rows)) + for i := range rows { + out = append(out, orderViewFromRow(&rows[i])) + } + writeJSON(w, map[string]any{"orders": out}) +} + // ─── GET /v1/pay/orders/{orderNo} ─────────────────────────────────────────── type orderStatusResponse struct { - OrderNo string `json:"order_no"` + orderView PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传 Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据 ExpiresAt *string `json:"expires_at,omitempty"` @@ -166,7 +223,7 @@ func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) { writePayErr(w, err) return } - resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"} + resp := orderStatusResponse{orderView: orderViewFromRow(row), PayStatus: st.Status, Activated: row.Status == "paid"} if row.SubID.Valid { if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil { s := exp.UTC().Format(time.RFC3339) diff --git a/server/internal/pay/handler_test.go b/server/internal/pay/handler_test.go index d2f5e2c..e983643 100644 --- a/server/internal/pay/handler_test.go +++ b/server/internal/pay/handler_test.go @@ -27,6 +27,7 @@ func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) { h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db) r := chi.NewRouter() r.Get("/v1/pay/catalog", h.Catalog) + r.Get("/v1/pay/orders", h.List) r.Post("/v1/pay/orders", h.CreateOrder) r.Get("/v1/pay/orders/{orderNo}", h.GetOrder) r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry) @@ -86,7 +87,7 @@ func TestGetOrder_OwnershipEnforced(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending", "subject":"s","amount_minor":2999,"currency":"CNY"}}`)) }) - if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil { + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } // 属主可查 @@ -116,7 +117,7 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) { w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`)) }) - _ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") body := []byte(`{"method":"alipay"}`) w := httptest.NewRecorder() router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1)) @@ -132,6 +133,118 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) { } } +// 下单时按支付方式落「展示金额 + 结算币种」(crypto→USDT 微元 / 法币→CNY 分)。 +// 单 rig(rig 内 DB 复用),mock 按转发来的 method 回不同 order_no。 +func TestCreateOrder_StoresDisplayAmountByMethod(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + var fwd struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&fwd) + _, _ = w.Write([]byte(`{"data":{"order_no":"pay-` + fwd.Method + `","session":{ + "render_type":"redirect","payload":{"url":"https://x"}}}}`)) + }) + cases := []struct { + method string + wantAmt int64 + wantCurr string + }{ + {"crypto", 4990000, "USDT"}, // pro_month USDT 微元 + {"alipay", 2999, "CNY"}, // pro_month CNY 分 + } + for _, tc := range cases { + body := []byte(`{"sku":"pro_month","method":"` + tc.method + `"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("%s: code = %d, body = %s", tc.method, w.Code, w.Body) + } + row, err := st.GetForUser(context.Background(), 1, "pay-"+tc.method) + if err != nil { + t.Fatalf("%s: 台账未落: %v", tc.method, err) + } + if row.AmountMinor != tc.wantAmt || row.Currency != tc.wantCurr { + t.Fatalf("%s: amount=%d curr=%q, want %d/%q", tc.method, row.AmountMinor, row.Currency, tc.wantAmt, tc.wantCurr) + } + } +} + +// 订单列表:只列本人、倒序、含台账富字段。 +func TestList_OwnershipAndFields(t *testing.T) { + router, st := newHandlerRig(t, nil) + ctx := context.Background() + // user 1 两单(先月后年),user 2 一单(不该出现在 user 1 列表) + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "o1", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "o2", "crypto", 34990000, "USDT"); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 2, "uuid-2", "pro_month", "o3", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + var resp struct { + Orders []orderView `json:"orders"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Orders) != 2 { + t.Fatalf("want 2 orders for user 1, got %d: %+v", len(resp.Orders), resp.Orders) + } + for _, o := range resp.Orders { + if o.OrderNo == "o3" { + t.Fatalf("越权:看到了他人订单 o3") + } + } + // 倒序:o2(pro_year, 后插)在前,且富字段完整 + first := resp.Orders[0] + if first.OrderNo != "o2" || first.SKU != "pro_year" || first.Plan != "pro" || + first.Method != "crypto" || first.AmountMinor != 34990000 || first.Currency != "USDT" || + first.Days != 366 || first.Status != "created" || first.CreatedAt == "" { + t.Fatalf("首单富字段不符: %+v", first) + } +} + +// 订单详情:回带台账富字段 + pay_status/activated。 +func TestGetOrder_EnrichedFields(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"USDT"}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_year", "pay001", "crypto", 34990000, "USDT"); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d", w.Code) + } + var resp struct { + OrderNo string `json:"order_no"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Method string `json:"method"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + CreatedAt string `json:"created_at"` + PayStatus string `json:"pay_status"` + Activated bool `json:"activated"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.SKU != "pro_year" || resp.Plan != "pro" || resp.Method != "crypto" || + resp.AmountMinor != 34990000 || resp.Currency != "USDT" || resp.CreatedAt == "" || + resp.PayStatus != "pending" || resp.Activated { + t.Fatalf("详情富字段不符: %+v", resp) + } +} + func TestCatalog(t *testing.T) { router, _ := newHandlerRig(t, nil) w := httptest.NewRecorder() diff --git a/server/internal/pay/store.go b/server/internal/pay/store.go index f0bcdf0..59641fe 100644 --- a/server/internal/pay/store.go +++ b/server/internal/pay/store.go @@ -23,6 +23,7 @@ type PurchaseRow struct { Channel string SubID sql.NullInt64 PaidAt sql.NullTime + CreatedAt time.Time } type Store struct { @@ -38,13 +39,15 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) } -// Insert 下单成功后落台账(status=created)。 -func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string) error { +// Insert 下单成功后落台账(status=created)。amountMinor/currency 是下单时的 +// **展示预估**(按支付方式的结算币种,见 DisplayAmountMinor),webhook 到账后由 +// MarkPaidTx 用实际结算金额覆盖——保证订单列表/详情在支付前也有金额可显。 +func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string, amountMinor int64, currency string) error { now := time.Now().UTC() _, err := s.db.ExecContext(ctx, - `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'created', ?, ?)`, - userID, bizRef, sku, outTradeNo, method, now, now) + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?)`, + userID, bizRef, sku, outTradeNo, method, amountMinor, currency, now, now) if err != nil { return fmt.Errorf("pay.Store.Insert: %w", err) } @@ -52,17 +55,42 @@ func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeN } const purchaseCols = `id, user_id, biz_ref, sku, out_trade_no, method, status, - amount_minor, currency, channel, sub_id, paid_at` + amount_minor, currency, channel, sub_id, paid_at, created_at` func scanPurchase(row *sql.Row) (*PurchaseRow, error) { var p PurchaseRow if err := row.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method, - &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt); err != nil { + &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil { return nil, err } return &p, nil } +// ListByUser 按用户列历史订单(created_at 倒序,复用 idx_pay_user 索引)。 +// 供「订单列表」页;limit 上限保护。 +func (s *Store) ListByUser(ctx context.Context, userID int64, limit int) ([]PurchaseRow, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+purchaseCols+` FROM pay_purchases WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`, + userID, limit) + if err != nil { + return nil, fmt.Errorf("pay.Store.ListByUser: %w", err) + } + defer rows.Close() + var out []PurchaseRow + for rows.Next() { + var p PurchaseRow + if err := rows.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method, + &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil { + return nil, fmt.Errorf("pay.Store.ListByUser scan: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + // GetForUser 按 (userID, outTradeNo) 取行——所有权校验由查询本身完成。 func (s *Store) GetForUser(ctx context.Context, userID int64, outTradeNo string) (*PurchaseRow, error) { return scanPurchase(s.db.QueryRowContext(ctx, diff --git a/server/internal/pay/webhook_sqlite_test.go b/server/internal/pay/webhook_sqlite_test.go index 5255d7b..fc7b3c7 100644 --- a/server/internal/pay/webhook_sqlite_test.go +++ b/server/internal/pay/webhook_sqlite_test.go @@ -60,7 +60,7 @@ func succeededPayload(orderNo, sku string) map[string]any { func TestWebhook_HappyPath(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil { + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT"); err != nil { t.Fatal(err) } w := deliver(t, h, succeededPayload("pay001", "pro_month")) @@ -92,7 +92,7 @@ func TestWebhook_HappyPath(t *testing.T) { func TestWebhook_RedeliveryIdempotent(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") deliver(t, h, succeededPayload("pay001", "pro_month")) var exp1 time.Time _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1) @@ -117,8 +117,8 @@ func TestWebhook_RedeliveryIdempotent(t *testing.T) { func TestWebhook_TwoOrdersStack(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") - _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") + _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto", 12990000, "USDT") deliver(t, h, succeededPayload("pay001", "pro_month")) deliver(t, h, succeededPayload("pay002", "pro_quarter")) var n int diff --git a/server/internal/store/pay_migration_rehearsal_test.go b/server/internal/store/pay_migration_rehearsal_test.go index 8321ddc..993750a 100644 --- a/server/internal/store/pay_migration_rehearsal_test.go +++ b/server/internal/store/pay_migration_rehearsal_test.go @@ -19,8 +19,8 @@ import ( // migratorAt builds a golang-migrate instance against a file-backed SQLite DB // and steps it to exactly `version` (unlike store.MigrateUp/Down, which always // target head/0). This is Task 8 Step 2's upgrade rehearsal: a real file DB -// (not :memory:) simulating a production sqlite store carrying pre-000021 rows -// through the 000021 subscriptions-table rebuild. +// (not :memory:) simulating a production sqlite store carrying pre-000023 rows +// through the 000023 subscriptions-table rebuild. func migratorAt(t *testing.T, database *sql.DB, version uint) { t.Helper() src, err := iofs.New(migrations.SQLiteFS, "sqlite") @@ -44,7 +44,7 @@ func migratorAt(t *testing.T, database *sql.DB, version uint) { } } -// insertLegacySubRow inserts one subscriptions row with a pre-000021 source +// insertLegacySubRow inserts one subscriptions row with a pre-000023 source // value ('trial' or 'code') for a fresh user, returning the assigned user_id // and subscription id. func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source string) (userID, subID int64) { @@ -82,7 +82,7 @@ func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source strin } // TestSQLitePayMigrationRehearsal_UpgradeWithData is Task 8 Step 2: rehearse -// the 000021 upgrade (subscriptions table rebuild for source='pay') against a +// the 000023 upgrade (subscriptions table rebuild for source='pay') against a // file-backed SQLite DB pre-loaded with real 'trial'/'code' rows, and assert // no rows are lost and the id/AUTOINCREMENT sequence is preserved. func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { @@ -101,15 +101,15 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { _ = trialUserID _ = codeUserID - // 2. Up to 000021 — subscriptions_new rebuild + pay_purchases creation. - migratorAt(t, database, 21) + // 2. Up to 000023 — subscriptions_new rebuild + pay_purchases creation. + migratorAt(t, database, 23) v, dirty, err := store.MigrateVersion(database, "sqlite") if err != nil { t.Fatalf("MigrateVersion: %v", err) } - if dirty || v != 21 { - t.Fatalf("after up to 21: version=%d dirty=%v, want 21/false", v, dirty) + if dirty || v != 23 { + t.Fatalf("after up to 23: version=%d dirty=%v, want 23/false", v, dirty) } // 3. Row count and ids preserved across the rebuild. @@ -118,7 +118,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { t.Fatalf("count subscriptions: %v", err) } if count != 2 { - t.Errorf("subscriptions count after 000021 = %d, want 2 (rows lost in rebuild)", count) + t.Errorf("subscriptions count after 000023 = %d, want 2 (rows lost in rebuild)", count) } for _, want := range []struct { @@ -127,7 +127,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { }{{trialSubID, "trial"}, {codeSubID, "code"}} { var gotSource string if err := database.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, want.id).Scan(&gotSource); err != nil { - t.Errorf("subscription id=%d missing after 000021: %v", want.id, err) + t.Errorf("subscription id=%d missing after 000023: %v", want.id, err) continue } if gotSource != want.source { @@ -146,7 +146,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { trialUserID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), ) if err != nil { - t.Fatalf("insert source='pay' subscription after 000021: %v", err) + t.Fatalf("insert source='pay' subscription after 000023: %v", err) } paySubID, err := res.LastInsertId() if err != nil { @@ -156,10 +156,10 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { t.Errorf("pay subscription id=%d did not continue AUTOINCREMENT sequence (prior max id=%d)", paySubID, codeSubID) } - // 5. pay_purchases table exists and is empty (fresh table from 000021). + // 5. pay_purchases table exists and is empty (fresh table from 000023). var payCount int if err := database.QueryRow(`SELECT COUNT(*) FROM pay_purchases`).Scan(&payCount); err != nil { - t.Fatalf("count pay_purchases (table should exist post-000021): %v", err) + t.Fatalf("count pay_purchases (table should exist post-000023): %v", err) } if payCount != 0 { t.Errorf("pay_purchases count = %d, want 0 (fresh table)", payCount) @@ -167,11 +167,11 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { } // TestSQLitePayMigrationRehearsal_DownUpIdempotent covers the second half of -// Task 8 Step 2: with only pre-000021 ('trial'/'code') data present, down -// (rollback 000021) then up (re-apply) must be idempotent and lossless. +// Task 8 Step 2: with only pre-000023 ('trial'/'code') data present, down +// (rollback 000023) then up (re-apply) must be idempotent and lossless. // // It also documents an intentional safety property: once a source='pay' row -// exists, 000021's down.sql (which rebuilds subscriptions with the stricter +// exists, 000023's down.sql (which rebuilds subscriptions with the stricter // CHECK (source IN ('trial','code'))) correctly REFUSES to downgrade rather // than silently dropping paid-subscription rows — see the trailing assertion. func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { @@ -186,9 +186,9 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { _, trialSubID := insertLegacySubRow(t, database, "trial2", "trial") _, codeSubID := insertLegacySubRow(t, database, "code2", "code") - migratorAt(t, database, 21) + migratorAt(t, database, 23) - // down: 000021 -> 000020. No 'pay' rows exist yet, so this must succeed + // down: 000023 -> 000020. No 'pay' rows exist yet, so this must succeed // and preserve the trial/code rows with their original ids. migratorAt(t, database, 20) @@ -219,14 +219,14 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { t.Errorf("pay_purchases table still present after down to 000020 (err=%v)", err) } - // up: 000020 -> 000021 again — idempotent re-apply, same rows/ids. - migratorAt(t, database, 21) + // up: 000020 -> 000023 again — idempotent re-apply, same rows/ids. + migratorAt(t, database, 23) v, dirty, err = store.MigrateVersion(database, "sqlite") if err != nil { t.Fatalf("MigrateVersion after re-up: %v", err) } - if dirty || v != 21 { - t.Fatalf("after re-up to 21: version=%d dirty=%v, want 21/false", v, dirty) + if dirty || v != 23 { + t.Fatalf("after re-up to 23: version=%d dirty=%v, want 23/false", v, dirty) } if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil { t.Fatalf("count subscriptions after re-up: %v", err) @@ -237,7 +237,7 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { // Safety-net documentation: once a source='pay' row exists, down must be // refused (CHECK (source IN ('trial','code')) on the down-rebuilt table), - // not silently drop it. This is NOT a bug — see 000021.down.sql. + // not silently drop it. This is NOT a bug — see 000023.down.sql. var planID int64 if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil { t.Fatalf("lookup pro plan id: %v", err) diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 01fae41..5e9ee09 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 21 { - t.Errorf("version = %d, want 21", v) + if v != 23 { + t.Errorf("version = %d, want 23", v) } // 2. Core tables exist.