RFC Task Breakdown — Self-Subs Phase 1
RFC: self-subs.md · Mode: Horizontal (Phase 1 UI mocked → Phase 2 API integration) · Repos: hub-chat, hub-service, hub_core, moderator-be
Reconnaissance corrections to the RFC (read first)
Verified against the actual checkouts (hub-chat, hub-service, hub_core, moderator-be). Three findings materially correct the RFC's assumptions:
| # | RFC assumed | Reality (verified) | Impact |
|---|---|---|---|
| 1 | Models, interactor & migration live in hub-service (app/models/, Migration[7.0]) | They live in hub_core (/Users/mekari/Documents/repos/hub_core). hub-service is only the Grape API layer. Model = Models::Billing::OrganizationPackage (table organization_packages); migrations in hub_core/database/billing/db/migrate/, ActiveRecord::Migration[6.1] | Migration + interactor tasks target hub_core, not hub-service |
| 2 | V3 gate = billing_version === 'v3' (Open Q7 blocker) | Resolved: value is '3.0.0'; a billing_v3? helper already exists on the model (billing_version.eql?('3.0.0')) | Open Q7 closed. FE must not compare 'v3'; better to expose a BE-computed boolean |
| 3 | moderator-be toggle path "TBD" (Open Q6 blocker) | Resolved: app/views/billing/accounts/show.html.erb → accounts_controller#update_status → use_cases/accounts/update_account.rb → …/mekari_one/update_remaining_balance.rb (which already pigeon_puts the modpanel PUT) | Open Q6 closed. The modpanel call path already exists; the new param rides along |
Also confirmed: platform HTTP client is Pigeon (not Faraday) — use it for the Mekari Billing proxy.
Contract update (2026-06-30) — Self-Checkout API v4
The Self-Checkout API Contract v4 landed and was folded into the RFC. Net effect on this breakdown:
| Was blocked | Now |
|---|---|
| Q3 self-sub create contract | ✅ Resolved — POST /api/v4/invoiceables/self-checkout, Bearer Mekari token (scope post_invoiceables), returns {invoiceable_id, payment_link}, idempotency external_ref_id, sales_order_source: jurnal_qontak. Task 2.3 can build the New Subscription + Upgrade create proxy for real (map new_sub→new_subscription, upgrade→upgrade_main). |
| Q5 webhook | ✅ Resolved — paid callback is MekariPay → Billing only; no Qontak webhook. |
| Q2 reconciliation | ⚠️ Reframed — SO provisioned synchronously at create-time (PI rolls back → 422 on SO-create failure). |
Still blocked (calls stay stubbed/commented):
- Q11 — no
renewalbilling_typeexists → the Renewal create call (SS-S03) cannot be mapped. - Q12 — the create payload needs
company_record+subscribed_packages+billing_period+ dates; where hub-service sources this per CID is unspecified.
Response field is payment_link (not checkout_url) + invoiceable_id.
Scope change (2026-07-01) — Q1/Q4 resolved, SS-S05 descoped
Per product direction:
- Q4 resolved — after payment the client is redirected to the hub-chat invoices page (existing
features/subscriptions/invoices/). No success/cancel param parsing on/subscriptions/packages; the FE simply lands on the invoices surface on return. - Q1 resolved by descoping — the pending-SO/Invoicable list is owned by the BI team; Qontak builds no Invoicable API. Double-payment is handled upstream (Billing
external_ref_ididempotency +rls_double_payment_validation). Consequently SS-S05 (Pending SO Warning Modal) is descoped from Qontak: Task 1.2 is removed, the invoicable proxy is dropped from Task 2.3, anduseCheckInvoicableis dropped from Task 2.5. Renewal (SS-S03) now goes straight to initiate.
Only 2 blockers remain: Q11 (renewal billing_type) and Q12 (create-payload sourcing). Effort drops 17.5 → 14 md (see updated summary).
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 4 | — | 1.5 | 5.5 |
| Phase 2 — API integration | 1 | 5.5 | 2 | 8.5 |
| Grand total | 5 | 5.5 | 3.5 | 14 |
Revised 2026-07-01: SS-S05 descoped (Task 1.2 removed: −2 FE / −0.5 QA); invoicable dropped from Task 2.3 (−0.5 BE) and Task 2.5 (−0.5 FE). Prior total was 17.5 md.
Confidence: medium–high (after the 2026-06-30 contract integration + 2026-07-01 scope change). Create contract (Q3) and webhook (Q5) confirmed; Q1 (Invoicable) and Q4 (redirect) resolved by descoping/decision. Only 2 blockers remain — renewal billing_type (Q11) and create-payload sourcing (Q12) — both isolated to the Renewal path and the create-call body. All UI patterns, the V3 value, and the moderator-be path are verified. New Sub + Upgrade are fully buildable end-to-end once Q12 is answered.
Phase 1 — UI (APIs mocked)
Task 1.1: [FE] Eligibility gate + self-service CTAs (SS-S01, SS-S02, SS-S03, SS-S04, SS-S06)
A Billing-V3 client with the feature enabled sees the correct New Subscription / Renew / Upgrade buttons on
/subscriptions/packages; everyone else sees the page exactly as today.
Status: ✅ Actionable (CTA click calls a mocked useInitiateSelfSubs stub; real endpoint wired in Task 2.5)
Design reference: n/a — design pending (RFC §1 Design References; PRD §7/§8 — all frames pending)
What to build
Extend BillingStore with the three eligibility fields, compute eligibility booleans in PackageDetails.vue, and render the three guarded CTA buttons (with loading/error states) inside PackageInfoComponent.vue. The initiate call is a mocked stub returning a fake payment_link (the field the real Self-Checkout endpoint returns).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | common/store/BillingStore.ts | Add billing_version?: string, self_subs_enabled?: boolean, disable_self_renewal?: boolean to interface BillingInfo + DEFAULT_BILLING_INFO (default false/undefined) |
| extend | features/subscriptions/packages/PackageDetails.vue | Compute isV3SelfSubsEnabled (billing_version === '3.0.0' && self_subs_enabled) and isSelfRenewalDisabled; pass as props to PackageInfoComponent |
| extend | features/subscriptions/packages/PackageInfoComponent.vue | Add isV3SelfSubsEnabled/isSelfRenewalDisabled props; render New Sub / Renew / Upgrade MpButtons with v-if guards + MpSpinner loading + toast.notify error |
| extend | features/subscriptions/composables/usePackages.ts | Add useInitiateSelfSubs() — mocked: returns { payment_link: 'https://mock' }; real $customFetch added Task 2.5 |
| create | features/subscriptions/packages/__tests__/PackageInfoComponent.spec.ts | CTA visibility matrix + click → mock initiate + loading/error |
Implementation steps
- Explore: open
common/store/BillingStore.ts(verified) — readinterface BillingInfo(lines 4–19) andDEFAULT_BILLING_INFO(105–115); note$customFetch('/api/core/v1/billings/info')at line 137. - Red: create
features/subscriptions/packages/__tests__/PackageInfoComponent.spec.ts(followfeatures/subscriptions/packages/__tests__/UsageDetail.spec.ts). Assert: no CTAs whenbilling_version !== '3.0.0'; New Sub shows whenis_trial; Renew+Upgrade when active; Renew/New Sub hidden whendisable_self_renewalbut Upgrade stays. Runpnpm test features/subscriptions/packages→ fail. - Scaffold: extend the
BillingInfointerface + defaults; add the eligibility computeds inPackageDetails.vueand pass props; add the CTA block + props toPackageInfoComponent.vue. - Wire state: read eligibility from props; trial/active state from
useSubscriptionPackages(is_trial,package_status) already inPackageDetails.vue. - Implement: click handler calls mocked
useInitiateSelfSubs(flowType); on resolvewindow.location.href = payment_link; on rejecttoast.notify({ position:'top-center', variant:'error', title:'Something went wrong. Please try again.' }). - Green:
pnpm test features/subscriptions/packages. - Quality gate:
pnpm type-check && pnpm lint.
Acceptance criteria
- Non-V3 / flag-OFF client: page byte-identical to today, zero CTAs (SS-S01/AC-1, ERR-1)
- Trial V3: New Subscription CTA only (SS-S02/AC-1,AC-3)
- Active V3: Renew + Upgrade CTAs (SS-S03/AC-1, SS-S04/AC-1)
-
disable_self_renewal=true: Renew + New Sub hidden, Upgrade still shown (SS-S06/AC-1) - Click → spinner → redirect to (mock)
payment_link; error → toast (SS-S0x/ERR-1)
Test strategy
Vitest mounts PackageInfoComponent with a mocked useInitiateSelfSubs (returns fake payment_link) and varied eligibility props; key assertion is the CTA visibility matrix and that click invokes the mock with the right flow_type.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 3 |
| Backend | — |
| QA | 1 |
| Total | 4 |
Assumptions: reuses existing
MpButton/MpSpinner/toastfrom@mekari/pixel3@1.0.12; eligibility props pattern mirrorsSubscriptionsLayout.vueflag checks; no new deps.
Run to verify
cd /Users/mekari/Documents/repos/hub-chat && pnpm test features/subscriptions/packages && pnpm type-check && pnpm lint
Depends on
- Mocked now; real data from Task 2.2 (GET /info fields) + real call from Task 2.5
Task 1.2: [FE] Pending SO Warning Modal (SS-S05) — ❌ DESCOPED (2026-07-01)
Removed from Qontak scope. The pending-SO/Invoicable list is owned by the BI team and double-payment is handled upstream by Billing (
external_ref_ididempotency +rls_double_payment_validation), so Qontak builds no Invoicable API and no warning modal (Q1 resolved by descoping).PendingSOWarningModal.vueanduseCheckInvoicableare not built; the Renew CTA (Task 1.1) goes straight touseInitiateSelfSubs('renewal'). Effort removed: −2 FE / −0.5 QA. If a Qontak-side pre-check is ever needed, reopen as a new task.
Task 1.3: [FE] moderator-be — Disable Self-Renewal toggle UI (SS-S06)
A Qontak admin can flip a "Disable Self-Renewal" switch on a CID's billing settings page to block that customer from self-serving renewals/new subscriptions.
Status: ✅ Actionable (view + Stimulus + form display; the hub-service param consumption is Task 2.4)
Design reference: n/a — design pending (PRD §8) — uses moderator-be's existing Tailwind form-checkbox pattern
What to build
Add a checkbox toggle to the CID billing settings ERB view, reflecting the current value and submitting with the existing account-update form.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/views/billing/accounts/show.html.erb | Add <%= f.check_box :disable_self_renewal, class: "form-checkbox mr-1" %> + label in the settings form |
| extend | app/javascript/controllers/billing--accounts-show_controller.js | If needed, reflect/confirm toggle state on submit (reuse existing controller wired in the view) |
| extend | spec/controllers/api/v1/billing/accounts_controller_spec.rb | Assert the form renders the new field (param permit asserted in Task 2.4) |
Implementation steps
- Explore: open
app/views/billing/accounts/show.html.erb(current page; notebilling/subscriptions/show.html.erbis marked "WILL BE REMOVED"). Read the existing form + thedata-billing--accounts-show-*Stimulus hooks. Reference the checkbox pattern inapp/views/devise/sessions/new.html.erb:20. - Red: extend
spec/controllers/api/v1/billing/accounts_controller_spec.rbto assert the rendered form includesdisable_self_renewal. Runbundle exec rspec spec/controllers/api/v1/billing/accounts_controller_spec.rb→ fail. - Scaffold: add the
f.check_box+ label near the other account settings fields. - Wire: bind the checkbox's checked state to the account's current
disable_self_renewalvalue (read from the account data the page already loads). - Green: rerun the spec.
- Quality gate:
bundle exec rspec spec/controllers/api/v1/billing/.
Acceptance criteria
- Toggle renders on the CID billing settings page, reflecting current state (SS-S06/AC-2)
- Toggling + submitting the form posts
disable_self_renewal(consumed in Task 2.4) - Save failure surfaces an error and reverts (full path completed in Task 2.4) (SS-S06/ERR-1)
Test strategy
Controller/view spec asserts the checkbox is rendered and reflects the loaded account value. The actual param→hub-service flow is asserted in Task 2.4.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| Backend | — |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: reuses existing account-update form + Stimulus controller; no new route in moderator-be (rides the existing
update_statussubmit).
Run to verify
cd /Users/mekari/Documents/repos/moderator-be && bundle exec rspec spec/controllers/api/v1/billing/
Depends on
- Param consumption completed in Task 2.4
Phase 2 — API Integration
Task 2.1: [BE] Migration — add self-subs columns to organization_packages (SS-S01, SS-S06)
Persists the two per-CID flags that gate the whole feature.
Status: ✅ Actionable (resolves Open Q8)
What to build
An additive hub_core migration adding two non-null boolean columns defaulting false.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/billing/db/migrate/<YYYYMMDDHHMMSS>_add_self_subs_fields_to_organization_packages.rb | add_column :organization_packages, :self_subs_enabled, :boolean, null:false, default:false; same for :disable_self_renewal; index on self_subs_enabled |
| extend | hub_core/app/core/domains/models/billing/organization_package.rb | Expose new attributes (and optionally a self_subs_eligible? helper alongside billing_v3?) |
Implementation steps
- Explore: open
hub_core/app/core/domains/models/billing/organization_package.rb— notebilling_v3?(line ~145,billing_version.eql?('3.0.0')); open the latest migration…/20260609100300_*.rbfor theMigration[6.1]format. - Write migration: use
ActiveRecord::Migration[6.1], additive columns + index,YYYYMMDDHHMMSSstamp. - Run:
bundle exec rails db:migratethenbundle exec rails db:rollback STEP=1to confirm reversibility. - Quality gate: confirm columns exist on
organization_packages.
Acceptance criteria
-
db:migratesucceeds;db:rollbackrestores cleanly - Both columns exist on
organization_packages,NOT NULL default false - Existing rows need no backfill
Test strategy
Migration smoke only (no QA): migrate/rollback round-trip in the hub_core dev DB.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: target table confirmed as
organization_packages(recon); additive only.
Run to verify
cd /Users/mekari/Documents/repos/hub_core && bundle exec rails db:migrate && bundle exec rails db:rollback STEP=1
Depends on
- none (do first)
Task 2.2: [BE] Extend BillingInfo → expose V3 + self-subs flags on GET /billings/info (SS-S01, SS-S06)
The frontend can read whether a CID is V3, self-subs-enabled, and renewal-disabled from the billing info it already fetches on page load.
Status: ✅ Actionable
What to build
Extend the hub_core BillingInfo interactor/builder to include the new fields (and billing_version), and confirm the hub-service Grape GET /info passes them through.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/interactors/billings/billing_info.rb | Include self_subs_enabled, disable_self_renewal (and billing_version, already on the package) in the result |
| extend | hub_core/…/builders/billings/billing_info.rb (Builders::Billings::BillingInfo) | Emit the new fields in the response payload; recommend also emitting is_billing_v3 boolean via billing_v3? so FE avoids string compares |
| extend | hub-service app/services/api/core/v1/billings/resources/billings.rb | No route change; verify present(response:) includes the new fields |
| extend | spec/services/api/core/v1/billings/resources/billings_spec.rb | New assertion: /info response includes the 3 fields |
Implementation steps
- Explore: open
hub_core/app/core/domains/interactors/billings/billing_info.rb— note theorganization_packagealready carriesbilling_version; find where the response hash/builder is assembled. - Red: extend
billings_spec.rb(type: :request,stub_auth,response_as_json) to expectself_subs_enabled,disable_self_renewal,billing_versionindata. Runbundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb→ fail. - Implement: add the fields in the interactor/builder; expose
is_billing_v3boolean. - Green: rerun the spec.
- Quality gate:
bundle exec rspec spec/services/api/core/v1/billings/ && bundle exec rubocop.
Acceptance criteria
-
GET /billings/inforeturnsbilling_version,self_subs_enabled,disable_self_renewal(SS-S01/AC-1,AC-2; SS-S06/AC-1) - Response is additive — existing fields unchanged
- (Recommended)
is_billing_v3boolean emitted so FE compares a flag, not'3.0.0'
Test strategy
Request spec stubs auth and an organization with the new package columns; asserts the three fields appear in response_as_json['data']. Key assertion: field presence + correct boolean values.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: response is built by
Builders::Billings::BillingInfo; spans hub_core (logic) + hub-service (passthrough verify).billing_versionalready populated.
Run to verify
cd /Users/mekari/Documents/repos/hub_service && bundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb
Depends on
- Task 2.1 (columns must exist)
Task 2.3: [BE] New SelfSubscriptions Grape resource — initiate proxy (SS-S02, SS-S03, SS-S04)
The backend exposes the endpoint the frontend calls to start a self-service flow, proxying Mekari Billing's Self-Checkout create server-side so credentials never reach the browser.
Status: ⚠️ Partially unblocked (post-contract) — the New Subscription + Upgrade create proxy can now be built for real against POST /api/v4/invoiceables/self-checkout (Q3 resolved). Still stubbed: the Renewal create call (Q11 — no renewal billing_type) and the create-payload assembly (Q12 — where company_record/subscribed_packages/dates come from). Invoicable endpoint removed — owned by BI (Q1 descoped).
What to build
A new API::Core::V1::SelfSubscriptions Grape namespace with POST /self_subscriptions (initiate), mounting a Pigeon-based interactor. The initiate interactor wires the real Self-Checkout call for new_sub/upgrade; renewal + full payload sourcing remain stubbed with clear TODOs. No invoicable endpoint (BI-owned).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/services/api/core/v1/self_subscriptions/resources/self_subscriptions.rb | oauth2 :admin,:owner,:supervisor,:agent,:member; post '/'; organization_id = me.organization_id (never from body) |
| create | app/services/api/core/v1/self_subscriptions/routes.rb | mount API::Core::V1::SelfSubscriptions::Resources::SelfSubscriptions |
| extend | app/services/api/core/v1/routes.rb (or parent mount) | Register the new self_subscriptions routes (mirror billings/modpanels mount) |
| create | hub_core/…/interactors/self_subscriptions/initiate.rb | Real Pigeon call to POST /api/v4/invoiceables/self-checkout (Bearer Mekari token, scope post_invoiceables); map new_sub→new_subscription, upgrade→upgrade_main; sales_order_source: jurnal_qontak; external_ref_id idempotency; return {payment_link, invoiceable_id}. renewal → raise NotImplemented behind # TODO(Q11); payload fields not yet sourced → # TODO(Q12). 5s timeout + 1 retry (5xx/network only) |
| create | spec/services/api/core/v1/self_subscriptions/resources/self_subscriptions_spec.rb | new_sub/upgrade happy path → 200 {payment_link, invoiceable_id} (Pigeon stubbed at HTTP layer); Mekari 5xx→503; 422 passthrough; auth missing→401 |
Implementation steps
- Explore: open
app/services/api/core/v1/billings/routes.rb(mount pattern) and…/billings/resources/billings.rb(resource +Dry::Matcher+present/then_raise_error!). Openhub_core/app/core/domains/services/voice/service_apis.rbfor thePigeon::Clientproxy pattern. - Red: write the request spec —
post '/api/core/v1/self_subscriptions'withflow_type=new_subreturns 200 withdata.payment_link(Pigeon HTTP stubbed to the contract's 200 body); missing auth → 401; Mekari 5xx → 503. Runbundle exec rspec spec/services/api/core/v1/self_subscriptions/→ fail. - Scaffold: create the resource + routes; derive
organization_idfromme(security: never from body, per §3). - Implement:
initiatebuilds the real Self-Checkout request fornew_sub/upgradeand calls Pigeon with 5s/1-retry (5xx/network only; 422 not retried); map upstreampayment_link/invoiceable_idinto the envelope. Guardrenewalwith# TODO(Q11)and any unsourced payload field with# TODO(Q12). - Green: rerun specs (Pigeon stubbed via WebMock/its fixture at the HTTP layer).
- Quality gate:
bundle exec rspec spec/services/api/core/v1/self_subscriptions/ && bundle exec rubocop.
Acceptance criteria
-
POST /self_subscriptionswithflow_type ∈ {new_sub,upgrade}calls the real Self-Checkout endpoint and returnsdata.payment_link+invoiceable_id(SS-S02/AC-1, SS-S04/AC-1) -
flow_type=renewalreturns a clear not-implemented error behind# TODO(Q11)(no billing_type mapping yet) - Mekari 5xx/network → 503 after 1 retry; 422 (e.g. duplicate
external_ref_id) passed through, not retried -
organization_idderived from token, never request body (§3 security); auth missing → 401 - No invoicable endpoint is built (BI-owned, Q1 descoped)
- (pending Q12) create-payload sourcing confirmed before New Sub/Upgrade can pass real E2E
Test strategy
Request specs stub auth and stub the Pigeon HTTP call to the Self-Checkout contract's documented 200/422/5xx bodies (WebMock or Pigeon fixture). Key assertions: new_sub/upgrade → 200 payment_link; 5xx→503; 422 passthrough; renewal→not-implemented.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3 |
Assumptions: new namespace mirrors billings/modpanels; Pigeon is the proxy client (recon). New Sub/Upgrade create call built against the confirmed v4 contract; invoicable endpoint dropped (BI-owned). Follow-ups: renewal mapping (Q11), payload sourcing (Q12) — ~1 BAU day once resolved. (−0.5 BE vs prior for dropped invoicable.)
Run to verify
cd /Users/mekari/Documents/repos/hub_service && bundle exec rspec spec/services/api/core/v1/self_subscriptions/
Depends on
- none structurally · New Sub/Upgrade path buildable now · [External: renewal billing_type — Q11; create-payload sourcing — Q12 (pending, stubbed)]
Task 2.4: [BE] Extend modpanel PUT with disable_self_renewal + enforce gate (SS-S06)
The admin toggle actually persists, and the backend enforces it so a blocked CID can't bypass the FE and self-renew via the API.
Status: ✅ Actionable
What to build
Add the optional param to hub-service's modpanel subscriptions PUT, enforce disable_self_renewal server-side in the initiate path, and pass the param through moderator-be's controller→use-case chain.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/services/api/core/v1/modpanels/resources/subscriptions.rb | Add optional :disable_self_renewal, type: Boolean to the params do block (lines 13–37); persist via the interactor |
| extend | hub-service self_subscriptions interactor (Task 2.3) | Enforce: if disable_self_renewal and flow_type != 'upgrade' → 422 (§3 security) |
| extend | moderator-be app/controllers/api/v1/billing/accounts_controller.rb | Permit disable_self_renewal in the update_status contract |
| extend | moderator-be app/domains/core/use_cases/accounts/update_account.rb | Add to dry-validation contract; include in payload passed to UpdateRemainingBalance |
| extend | specs both repos | hub-service: toggle persists, gate returns 422; moderator-be: param permitted + forwarded |
Implementation steps
- Explore: hub-service
modpanels/resources/subscriptions.rb(oauth2 :modpanel, the longoptionallist); moderator-beupdate_account.rb(dry-validation contract +UpdateRemainingBalancecall at ~line 89) and…/mekari_one/update_remaining_balance.rb(pigeon_putto/api/core/v1/modpanels/subscriptions/#{external_company_account_id}, passes@paramsbody). - Red: hub-service spec — PUT with
disable_self_renewal:truesaves; initiate with gate ON +flow_type=renewal→ 422;flow_type=upgradeallowed. moderator-be spec — controller permits + forwards the param. Run both → fail. - Implement: add the optional param + persistence in hub-service; add the enforcement branch in the initiate interactor; add the param to moderator-be's contract + payload.
- Green: rerun both repos' specs.
- Quality gate:
bundle exec rspec+rubocopin each repo.
Acceptance criteria
- PUT modpanel subscriptions saves
disable_self_renewaltrue/false (SS-S06/AC-1,AC-2) - Initiate with gate ON + non-upgrade flow → 422 (server-side enforcement, §3)
- Upgrade flow remains allowed when gate ON (SS-S06/AC-1 deviation note)
- moderator-be
update_statuspermits + forwards the param to hub-service - Non-
:modpanelauth → 401 (unchanged)
Test strategy
hub-service request spec asserts persistence + the 422 gate branch; moderator-be controller spec asserts the param is permitted and lands in the UpdateRemainingBalance payload. Key assertion: enforcement 422 + param passthrough.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions:
update_remaining_balance.rbalready forwards arbitrary params (recon — "no change needed"), so moderator-be work is contract+payload only; enforcement reuses the Task 2.3 interactor.
Run to verify
cd /Users/mekari/Documents/repos/hub_service && bundle exec rspec spec/services/api/core/v1/modpanels/
cd /Users/mekari/Documents/repos/moderator-be && bundle exec rspec spec/controllers/api/v1/billing/
Depends on
- Task 2.1 (column) · Task 2.3 (initiate interactor for the gate) · completes Task 1.3 end-to-end
Task 2.5: [FE] Wire real initiate endpoint + invoices-page return + Mixpanel (SS-S02, SS-S03, SS-S04)
The CTAs stop using mocks and talk to the real backend; on return from Mekari Pay the client lands on the invoices page and analytics fire.
Status: ✅ Actionable for New Sub / Upgrade — replace the Task 1.1 mock with a real $customFetch to POST /self_subscriptions. Renewal wiring stays guarded until Q11. (No invoicable wiring — descoped; Q4 return is a plain navigation, no param parsing.)
Design reference: n/a — design pending (PRD §7/§8)
What to build
Convert useInitiateSelfSubs to a real $customFetch call, point the post-payment return at the hub-chat invoices page, and add the Mixpanel tracks from RFC §3.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/subscriptions/composables/usePackages.ts | Replace mock body: useInitiateSelfSubs → $customFetch('/api/core/v1/self_subscriptions', {method:'POST', body:{flow_type}}); on resolve window.location.href = payment_link. Renewal path stays guarded (Q11). |
| extend | features/subscriptions/packages/PackageDetails.vue | Post-payment return = navigate to the existing hub-chat invoices page (features/subscriptions/invoices/); no success/cancel param parsing. Refetch billing info on mount so state reflects the new subscription. |
| extend | features/subscriptions/composables/usePackages.ts | Add $mixpanel?.track(...) for self_subs_flow_initiated / payment_completed / payment_failed / initiation_failed |
| extend | features/subscriptions/packages/__tests__/PackageInfoComponent.spec.ts | Swap the initiate mock for $customFetch assertions (method, path, body, error handling) |
Implementation steps
- Explore: re-open
usePackages.ts— replicate the existing$customFetch+statusref pattern (lines 55–73) and the$mixpanel?.track(...)pattern (lines 258–262). Confirm the invoices route underfeatures/subscriptions/invoices/. - Red: update
PackageInfoComponent.spec.tsto assert$customFetchcalled with correct method/path/body and that a rejected initiate fires the error toast +self_subs_initiation_failed. Run → fail. - Implement: real
useInitiateSelfSubsbody; redirect topayment_link; post-return navigation to the invoices page + billing-info refetch; Mixpanel tracks at the right lifecycle points. Keep the renewal branch guarded (Q11). - Green:
pnpm test features/subscriptions/. - Quality gate:
pnpm type-check && pnpm lint && pnpm build.
Acceptance criteria
- Initiate (New Sub / Upgrade) calls real
POST /self_subscriptionsand redirects to returnedpayment_link(SS-S02/AC-2, SS-S04/AC-1) - Renewal initiate stays behind a stub/guard until Q11 (
renewalbilling_type) resolves (SS-S03) - On return from Mekari Pay, the client lands on the hub-chat invoices page; billing info refetched (Q4 resolved — no param parsing)
- All §3 Mixpanel events fire at the right points
Test strategy
Vitest mocks useNuxtApp().$customFetch and $mixpanel; asserts request shape, redirect to payment_link, and event names. Key mock: $customFetch; key assertion: correct method/path/body + error→toast+track.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| Backend | — |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: endpoint from Task 2.3 exists; invoices page already exists (recon); return is a plain navigation (Q4 resolved); no new deps. (−0.5 FE vs prior for dropped invoicable + redirect-param work.)
Run to verify
cd /Users/mekari/Documents/repos/hub-chat && pnpm test features/subscriptions/ && pnpm type-check && pnpm lint
Depends on
- Tasks 1.1, 2.2, 2.3 · [External: renewal billing_type — Q11 (renewal path only)]
Ordering rationale
- Critical path is the BE data spine → FE consumption: Task 2.1 (columns) → 2.2 (expose on
/info) unblocks the real eligibility data; do these first in Phase 2. The FE eligibility surface (1.1) can start immediately against mocks in parallel. - Phase 1 is fully parallelizable across people: 1.1 (hub-chat CTAs) and 1.3 (moderator-be toggle) touch disjoint files and can run concurrently. (Task 1.2 descoped.)
- Task 2.3 is the long pole — now partly unblocked: the New Sub + Upgrade create proxy builds against the confirmed
POST /api/v4/invoiceables/self-checkoutcontract. Renewal (Q11) and payload sourcing (Q12) stay stubbed with TODOs; invoicable dropped (BI-owned). - 2.4 closes the SS-S06 loop end-to-end (toggle UI in 1.3 → param + enforcement here); it depends on 2.1 and the 2.3 interactor.
- 2.5 is last — it can only swap mocks for real calls once 2.2/2.3 exist.
- Push externally now on the 2 remaining blockers: Q11 (renewal billing_type) and Q12 (create-payload data sourcing) — both isolated to the Renewal path / create-call body. Resolved since the last revision: Q3 (create contract), Q5 (webhook), Q1 (Invoicable → BI-owned, descoped), Q4 (return → invoices page), Q6/Q7/Q8 (repo recon).
Skipped / descoped stories
| Story | Reason |
|---|---|
| SS-S05 — Pending SO Warning Modal | Descoped from Qontak (2026-07-01) — pending-SO/Invoicable list owned by BI; double-payment handled upstream by Billing. Task 1.2 removed; no Qontak Invoicable API. |
| SS-S03 — Renewal (partial) | New Sub + Upgrade build now; the Renewal create call is guarded until Q11 (no renewal billing_type upstream). The Renew CTA + FE shell still ship. |
New Sub + Upgrade are buildable end-to-end pending only Q12 (create-payload sourcing). Resolved in-repo: Q6 (moderator-be path), Q7/Q8 (V3 '3.0.0' + organization_packages/hub_core).