Technical handoff · for reviewers · SB-12028 / MR !4396

Plan V2 Membership — admin + webhook backend

Migrate tích hợp Joy ↔ Joy Subscription từ product-based API (V1) → plan-based API (V2): đổi FK outer container + multi-product, single Joy admin UX, proactive sync. Kèm fix webhook activation-window (shared V1/V2) + activity log, và bộ auto-test webhook (Phase A–O, 61 PASS). Coexist V1 qua router gate; không breaking V1.

feature/membership-plan-v2 39 commits · 85 files · +11.7k/−41 verified on dev · pending QA

1Tóm tắt

Bối cảnh: Joy Subscription migrate product-based API (subscriptionProducts/) → plan-based (subscriptionPlans/). Joy Loyalty phải đổi tích hợp theo. Data model 1 doc/sellingPlanId (per-frequency joyData) KHÔNG đổi — V1 đã ship pattern này ≥18 tháng (membershipPlanService.createMembershipPlansBatch batch N docs per N freq). V2 thực chất chỉ đổi: (1) FK outer subscriptionProductId → subscriptionPlanId; (2) multi-product per container (V1 forced 1:1 qua productId); (3) admin flow chuyển sang app JS.

Single collection + discriminator: membershipPlans/ doc với version:'v1'|'v2'; webhook lookup unified qua getMembershipPlanBySellingPlanId (works cả 2). joyData lưu flat top-level ở cả V1 lẫn V2 (verified: mọi V2 write path ...sanitizeJoyDataV2() spread). Joy là proxy: push cấu trúc sang JS V2 BE (vốn 0 validation → cần validatePayloadBeforeJs defense-in-depth), lưu joyData riêng.

Proactive sync: V1 dùng lazy reconcile (cleanupOrphans lúc GET) — đủ vì admin ở Joy app, ít xoá từ JS. V2 admin ở JS app giàu hơn → merchant edit/delete trực tiếp nhiều → lazy reconcile không kịp → thêm webhook sellingPlans_removed cascade. Đây là hệ quả admin-flow đổi, không phải "JS thiếu webhook" (V1 cũng không có).

2Architecture & data flow

Write path — admin save plan (create/update)

CreatePlanPage
FE gom payload {ruleName, selectedItems, plans[], widgetSettings}
validatePayloadBeforeJs
defense-in-depth (JS BE không validate)
joySubV2IntegrationService
POST/PUT sang JS V2; R-OB-3 rollback nếu Joy fail
membershipPlanV2Repository
lưu 1 doc/frequency, joyData nested

Webhook path — Joy Sub → Joy (lifecycle + delete)

verifyJoySubWebhook
HMAC + API key + idempotency + shop lookup
joySubWebhookController
dispatch theo topic (7 topics)
joySubWebhookService
7-phase: fetch → validate → priority → tier → door → assign → tag
programService
processUpdate/UnassignExclusiveTier + createActivity

Quyết định thiết kế

Quyết địnhPhương án đã chọnVì sao (vs phương án khác)
Data model (1 doc/sellingPlanId)GIỮ NGUYÊN pattern V1, chỉ đổi FK outer + thêm version:'v2'Không phải design mới — V1 đã batch N docs per N freq ≥18 tháng. Cùng collection để unified webhook lookup; discriminator loại V1 tự nhiên (V1 thiếu version)
Multi-product containerV2 selectedItems[] (N product) vs V1 productId (forced 1:1)Đây là khác biệt thực sự của plan-based API; FE browse modal + BE schema theo đó
Orphan detectionLazy reconcile khi GET + thêm webhook delete cascadeV1 chỉ lazy reconcile là đủ (admin ở Joy, ít xoá từ JS). V2 admin ở JS → cần proactive; lazy reconcile giữ làm safety net
Dual-shape read (defensive)readPlanField(plan, f) đọc plan[f] ?? plan.joyData?.[f]Production cả V1 + V2 đều flat (plan[f]). Nhánh nested chỉ là defensive cho merge-path response shape; KHÔNG fix bug active. Áp dụng đồng bộ filter/sort/phase2-3/tag
Activity type cho webhook tier changeReuse ACTION_UPDATE_TIER + field source/event/reasonThêm type mới sẽ chạm Firestore IN-query 30-limit ở allActivitiesInAdminDisplay; reuse type + discriminate qua source/event an toàn hơn
Activation window source-of-truthĐọc dates từ Joy doc, không từ webhook payloadprepareJoySubPayload strip joyData khi sync → payload không có dates → gate luôn pass. Joy doc mới là nguồn đúng (fix bug shared V1/V2)
sellingPlans_removed cascadeBranch isContainerDeleted: full vs partial-by-idsJS Sub bắn theo SellingPlanGroup; full = xoá hết doc theo subscriptionPlanId, partial = xoá theo removedSellingPlanIds/joySubPlanIds (chunked IN-30)

3Files changed

Diff thật của V2 (merge-base 8f2f5ce81ddd6253): 85 files, +11.7k/−41. Các file lõi:

Backend

FileThay đổi±
controllers/membershipPlanV2Controller.jsREST handlers: CRUD + status + joyData fastpath + restore + orphansNEW +341
services/joyMembershipV2Service.jscreate/update/delete + R-OB-3 rollback + lazy reconcileNEW +540
services/joySubV2IntegrationService.jsproxy POST/PUT/DELETE sang JS V2 BENEW +276
services/joyMigrationV1ToV2Service.jsmigration V1→V2 (one-time)NEW +223
repositories/membershipPlanV2Repository.jsper-frequency ops + coverage + orphan + cascade delete (full/by-ids)NEW +255
helpers/validatePayloadBeforeJs.jsdefense-in-depth validation + widget fields whitelistNEW +284
middleware/ensureV2Shop.jsgate V2 routes theo JS plan-version (Redis 5-min cache, fail-closed)NEW +50
services/joySubWebhookService.jsreadPlanField dual-shape · isPlanDateValid gate · activity log · processSubscriptionPlanRemoved+138
services/programService.jsprocessUpdate/UnassignExclusiveTier nhận source/event/reason + createActivity+24
controllers/joySubWebhookController.jstopic subscription_plan/sellingPlans_removed dispatch+10
helpers/sanitizeJoyDataV2.js · helpers/membershipPlanV2Errors.jsjoyData sanitizer + error helperNEW +65
services/shopify/shopifyCollectionsService.jsGraphQL listCollections cho browse modalNEW +57
routes/api.js · config/joySub.jsđăng ký V2 routes + JS V2 endpoints+81
firestore-indexes/membershipPlans.json3 composite index V2 (subscriptionPlanId / orphaned / joySubPlanId)+

Frontend (pages/Membership/V2/)

FileThay đổi±
CreatePlanPage.jscreate+edit form, single planData state, save bar, freq editor + preview pane + switch popoverNEW +665
PlansListPage.jslist: tabs/search/pagination/bulk deleteNEW +475
OrphanedPlansPage.js · OrphanedPlansAlert.jsorphan management + alert badgeNEW +267
JoyDataSection.jsper-frequency tier mapping (tier · priority · window · tag)NEW +214
hooks/useBrowseProductsV2.jsproduct picker: collection filter, select-all, priceNEW +323
preview/*storefront preview clone (PurchaseOptionsBlock, VariantChips, PriceLine, MembershipPlanPreview…)NEW +550
WidgetSettingsSection.js · const/purchaseOptions.jswidget settings card mirror JS SubNEW +134
FrequencySwitchPopover.jsnút chuyển freq (mirror SelectPlanPopover)NEW +70
FrequencyDetailView / SummaryCard / OptionEditor / RequirementsSectionfreq sub-screensNEW +397
helpers/planFormHelpers.jsdefault factories + structure-change detectNEW +122
helpers/activity/prepareContent.jsrender membership context cho webhook tier change+26
locale/input/JoyMembershipV2.json (+10 outputs)i18n namespace V2 + widget + activity keys+241

Test infra (scripts/seed-joy-sub-webhook/)

FileThay đổi±
run-suite.jsPhase A–O runner (61 scenarios)NEW +1509
fixtures.js · payloads.js · sign.js · check.js · log-tail.jsseed/HMAC/assert helpers + seedV2PlanNEW +396
seed-joy-sub-webhook.js (CLI) · README.md · test-plan.mdCLI entry + docs + 61-scenario planNEW +525

4Backend webhook — fix + cải tiến

1 bug thật (shared V1/V2) + 1 cải tiến audit + 1 hardening defensive. Tất cả verify qua auto-test.

#LoạiNội dungFixVerified
1BUGActivation window bị ignore (ảnh hưởng cả V1 + V2, shared handler) — isPlanDateValid đọc dates từ webhook payload, nhưng prepareJoySubPayload strip joyData khi Joy sync sang JS → payload echo không có dates → gate luôn pass. Plan hết hạn vẫn promote, chưa tới ngày cũng promote.Đọc dates trực tiếp Joy doc (source-of-truth), không từ payload Phase C5/C6
2CẢI TIẾNTier change qua webhook không ghi activity (ASSIGN thiếu createActivity; UNASSIGN dùng source=admin sai) → CS/admin không truy được lịch sửCả 2 path pass source=JS_SUB_WEBHOOK + event + reason{contractId,sellingPlanId,planTitle}; UI render context Phase N (C1/C2/C3 UI)
3DEFENSIVEKhông phải bug active. readPlanField dual-shape (flat ?? nested) phòng trường hợp merge-path trả nested joyData. Production V2 doc lưu flat y hệt V1 nên code cũ vẫn đọc đúng — fix này là hardening, đi kèm khi refactor activity log.readPlanField áp dụng đồng bộ filter/sort/phase2-3/tag Phase K1 + C

5Edge cases & test plan

Suite backend chạy local emulator → staging Firestore. 61 PASS / 0 fail / ~13 skip (cần fault injection hoặc real Shopify customer).

PhaseCoverageNotable cases
A–BMiddleware + dispatchmissing header 400, wrong key 401, bad HMAC 401, idempotent replay, unknown shop 503, 7 topic routing
C–DfindBestMembershipPlan + validationempty plans, unknown sellingPlan, no tier, future-start/past-end gate (Bug 1), nested joyData (Bug 2)
E–GContract/priority + tier + door deduppriority skip/proceed, tier-not-found, fallback, out-of-order timestamp block
H–JTier change side effects + tag + integrationalready-assigned skip, unassign fallback, tag add/remove, happy path per event
K–MEdge + priority deep + multi-tierflat vs nested regression, priority 0, multi-plan sort, Silver→Subscriber switch, flip-flop
NPer-field REAL customersC1 assign / C2 unassign-fallback / C3 switch — verify Firestore state + activity feed UI
OsellingPlans_removed cascadefull container delete, partial by sellingPlanIds, partial by joySubPlanIds, replay idempotent, missing-planId defensive skip
QA gợi ý: chạy node scripts/seed-joy-sub-webhook/run-suite.js --phase=all với emulator + staging service account. Hoặc test thật sau khi đấu staging Joy ↔ Joy Sub.

6Follow-ups & known limitations