Skip to main content

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 assumedReality (verified)Impact
1Models, 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
2V3 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
3moderator-be toggle path "TBD" (Open Q6 blocker)Resolved: app/views/billing/accounts/show.html.erbaccounts_controller#update_statususe_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 blockedNow
Q3 self-sub create contractResolvedPOST /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 webhookResolved — 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 renewal billing_type exists → 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_id idempotency + 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, and useCheckInvoicable is 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 / AreaFE daysBE daysQA daysTotal
Phase 1 — UI (mocked)41.55.5
Phase 2 — API integration15.528.5
Grand total55.53.514

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

ActionFileWhat changes
extendcommon/store/BillingStore.tsAdd billing_version?: string, self_subs_enabled?: boolean, disable_self_renewal?: boolean to interface BillingInfo + DEFAULT_BILLING_INFO (default false/undefined)
extendfeatures/subscriptions/packages/PackageDetails.vueCompute isV3SelfSubsEnabled (billing_version === '3.0.0' && self_subs_enabled) and isSelfRenewalDisabled; pass as props to PackageInfoComponent
extendfeatures/subscriptions/packages/PackageInfoComponent.vueAdd isV3SelfSubsEnabled/isSelfRenewalDisabled props; render New Sub / Renew / Upgrade MpButtons with v-if guards + MpSpinner loading + toast.notify error
extendfeatures/subscriptions/composables/usePackages.tsAdd useInitiateSelfSubs()mocked: returns { payment_link: 'https://mock' }; real $customFetch added Task 2.5
createfeatures/subscriptions/packages/__tests__/PackageInfoComponent.spec.tsCTA visibility matrix + click → mock initiate + loading/error

Implementation steps

  1. Explore: open common/store/BillingStore.ts (verified) — read interface BillingInfo (lines 4–19) and DEFAULT_BILLING_INFO (105–115); note $customFetch('/api/core/v1/billings/info') at line 137.
  2. Red: create features/subscriptions/packages/__tests__/PackageInfoComponent.spec.ts (follow features/subscriptions/packages/__tests__/UsageDetail.spec.ts). Assert: no CTAs when billing_version !== '3.0.0'; New Sub shows when is_trial; Renew+Upgrade when active; Renew/New Sub hidden when disable_self_renewal but Upgrade stays. Run pnpm test features/subscriptions/packages → fail.
  3. Scaffold: extend the BillingInfo interface + defaults; add the eligibility computeds in PackageDetails.vue and pass props; add the CTA block + props to PackageInfoComponent.vue.
  4. Wire state: read eligibility from props; trial/active state from useSubscriptionPackages (is_trial, package_status) already in PackageDetails.vue.
  5. Implement: click handler calls mocked useInitiateSelfSubs(flowType); on resolve window.location.href = payment_link; on reject toast.notify({ position:'top-center', variant:'error', title:'Something went wrong. Please try again.' }).
  6. Green: pnpm test features/subscriptions/packages.
  7. 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

DisciplineDays
Frontend3
Backend
QA1
Total4

Assumptions: reuses existing MpButton/MpSpinner/toast from @mekari/pixel3@1.0.12; eligibility props pattern mirrors SubscriptionsLayout.vue flag 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_id idempotency + rls_double_payment_validation), so Qontak builds no Invoicable API and no warning modal (Q1 resolved by descoping). PendingSOWarningModal.vue and useCheckInvoicable are not built; the Renew CTA (Task 1.1) goes straight to useInitiateSelfSubs('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

ActionFileWhat changes
extendapp/views/billing/accounts/show.html.erbAdd <%= f.check_box :disable_self_renewal, class: "form-checkbox mr-1" %> + label in the settings form
extendapp/javascript/controllers/billing--accounts-show_controller.jsIf needed, reflect/confirm toggle state on submit (reuse existing controller wired in the view)
extendspec/controllers/api/v1/billing/accounts_controller_spec.rbAssert the form renders the new field (param permit asserted in Task 2.4)

Implementation steps

  1. Explore: open app/views/billing/accounts/show.html.erb (current page; note billing/subscriptions/show.html.erb is marked "WILL BE REMOVED"). Read the existing form + the data-billing--accounts-show-* Stimulus hooks. Reference the checkbox pattern in app/views/devise/sessions/new.html.erb:20.
  2. Red: extend spec/controllers/api/v1/billing/accounts_controller_spec.rb to assert the rendered form includes disable_self_renewal. Run bundle exec rspec spec/controllers/api/v1/billing/accounts_controller_spec.rb → fail.
  3. Scaffold: add the f.check_box + label near the other account settings fields.
  4. Wire: bind the checkbox's checked state to the account's current disable_self_renewal value (read from the account data the page already loads).
  5. Green: rerun the spec.
  6. 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

DisciplineDays
Frontend1
Backend
QA0.5
Total1.5

Assumptions: reuses existing account-update form + Stimulus controller; no new route in moderator-be (rides the existing update_status submit).

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

ActionFileWhat changes
createhub_core/database/billing/db/migrate/<YYYYMMDDHHMMSS>_add_self_subs_fields_to_organization_packages.rbadd_column :organization_packages, :self_subs_enabled, :boolean, null:false, default:false; same for :disable_self_renewal; index on self_subs_enabled
extendhub_core/app/core/domains/models/billing/organization_package.rbExpose new attributes (and optionally a self_subs_eligible? helper alongside billing_v3?)

Implementation steps

  1. Explore: open hub_core/app/core/domains/models/billing/organization_package.rb — note billing_v3? (line ~145, billing_version.eql?('3.0.0')); open the latest migration …/20260609100300_*.rb for the Migration[6.1] format.
  2. Write migration: use ActiveRecord::Migration[6.1], additive columns + index, YYYYMMDDHHMMSS stamp.
  3. Run: bundle exec rails db:migrate then bundle exec rails db:rollback STEP=1 to confirm reversibility.
  4. Quality gate: confirm columns exist on organization_packages.

Acceptance criteria

  • db:migrate succeeds; db:rollback restores 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

DisciplineDays
Frontend
Backend0.5
QA
Total0.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

ActionFileWhat changes
extendhub_core/app/core/domains/interactors/billings/billing_info.rbInclude self_subs_enabled, disable_self_renewal (and billing_version, already on the package) in the result
extendhub_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
extendhub-service app/services/api/core/v1/billings/resources/billings.rbNo route change; verify present(response:) includes the new fields
extendspec/services/api/core/v1/billings/resources/billings_spec.rbNew assertion: /info response includes the 3 fields

Implementation steps

  1. Explore: open hub_core/app/core/domains/interactors/billings/billing_info.rb — note the organization_package already carries billing_version; find where the response hash/builder is assembled.
  2. Red: extend billings_spec.rb (type: :request, stub_auth, response_as_json) to expect self_subs_enabled, disable_self_renewal, billing_version in data. Run bundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb → fail.
  3. Implement: add the fields in the interactor/builder; expose is_billing_v3 boolean.
  4. Green: rerun the spec.
  5. Quality gate: bundle exec rspec spec/services/api/core/v1/billings/ && bundle exec rubocop.

Acceptance criteria

  • GET /billings/info returns billing_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_v3 boolean 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

DisciplineDays
Frontend
Backend1
QA0.5
Total1.5

Assumptions: response is built by Builders::Billings::BillingInfo; spans hub_core (logic) + hub-service (passthrough verify). billing_version already 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

ActionFileWhat changes
createapp/services/api/core/v1/self_subscriptions/resources/self_subscriptions.rboauth2 :admin,:owner,:supervisor,:agent,:member; post '/'; organization_id = me.organization_id (never from body)
createapp/services/api/core/v1/self_subscriptions/routes.rbmount API::Core::V1::SelfSubscriptions::Resources::SelfSubscriptions
extendapp/services/api/core/v1/routes.rb (or parent mount)Register the new self_subscriptions routes (mirror billings/modpanels mount)
createhub_core/…/interactors/self_subscriptions/initiate.rbReal 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)
createspec/services/api/core/v1/self_subscriptions/resources/self_subscriptions_spec.rbnew_sub/upgrade happy path → 200 {payment_link, invoiceable_id} (Pigeon stubbed at HTTP layer); Mekari 5xx→503; 422 passthrough; auth missing→401

Implementation steps

  1. Explore: open app/services/api/core/v1/billings/routes.rb (mount pattern) and …/billings/resources/billings.rb (resource + Dry::Matcher + present/then_raise_error!). Open hub_core/app/core/domains/services/voice/service_apis.rb for the Pigeon::Client proxy pattern.
  2. Red: write the request spec — post '/api/core/v1/self_subscriptions' with flow_type=new_sub returns 200 with data.payment_link (Pigeon HTTP stubbed to the contract's 200 body); missing auth → 401; Mekari 5xx → 503. Run bundle exec rspec spec/services/api/core/v1/self_subscriptions/ → fail.
  3. Scaffold: create the resource + routes; derive organization_id from me (security: never from body, per §3).
  4. Implement: initiate builds the real Self-Checkout request for new_sub/upgrade and calls Pigeon with 5s/1-retry (5xx/network only; 422 not retried); map upstream payment_link/invoiceable_id into the envelope. Guard renewal with # TODO(Q11) and any unsourced payload field with # TODO(Q12).
  5. Green: rerun specs (Pigeon stubbed via WebMock/its fixture at the HTTP layer).
  6. Quality gate: bundle exec rspec spec/services/api/core/v1/self_subscriptions/ && bundle exec rubocop.

Acceptance criteria

  • POST /self_subscriptions with flow_type ∈ {new_sub,upgrade} calls the real Self-Checkout endpoint and returns data.payment_link + invoiceable_id (SS-S02/AC-1, SS-S04/AC-1)
  • flow_type=renewal returns 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_id derived 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

DisciplineDays
Frontend
Backend2.5
QA0.5
Total3

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

ActionFileWhat changes
extendapp/services/api/core/v1/modpanels/resources/subscriptions.rbAdd optional :disable_self_renewal, type: Boolean to the params do block (lines 13–37); persist via the interactor
extendhub-service self_subscriptions interactor (Task 2.3)Enforce: if disable_self_renewal and flow_type != 'upgrade' → 422 (§3 security)
extendmoderator-be app/controllers/api/v1/billing/accounts_controller.rbPermit disable_self_renewal in the update_status contract
extendmoderator-be app/domains/core/use_cases/accounts/update_account.rbAdd to dry-validation contract; include in payload passed to UpdateRemainingBalance
extendspecs both reposhub-service: toggle persists, gate returns 422; moderator-be: param permitted + forwarded

Implementation steps

  1. Explore: hub-service modpanels/resources/subscriptions.rb (oauth2 :modpanel, the long optional list); moderator-be update_account.rb (dry-validation contract + UpdateRemainingBalance call at ~line 89) and …/mekari_one/update_remaining_balance.rb (pigeon_put to /api/core/v1/modpanels/subscriptions/#{external_company_account_id}, passes @params body).
  2. Red: hub-service spec — PUT with disable_self_renewal:true saves; initiate with gate ON + flow_type=renewal → 422; flow_type=upgrade allowed. moderator-be spec — controller permits + forwards the param. Run both → fail.
  3. 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.
  4. Green: rerun both repos' specs.
  5. Quality gate: bundle exec rspec + rubocop in each repo.

Acceptance criteria

  • PUT modpanel subscriptions saves disable_self_renewal true/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_status permits + forwards the param to hub-service
  • Non-:modpanel auth → 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

DisciplineDays
Frontend
Backend1.5
QA0.5
Total2

Assumptions: update_remaining_balance.rb already 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

ActionFileWhat changes
extendfeatures/subscriptions/composables/usePackages.tsReplace 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).
extendfeatures/subscriptions/packages/PackageDetails.vuePost-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.
extendfeatures/subscriptions/composables/usePackages.tsAdd $mixpanel?.track(...) for self_subs_flow_initiated / payment_completed / payment_failed / initiation_failed
extendfeatures/subscriptions/packages/__tests__/PackageInfoComponent.spec.tsSwap the initiate mock for $customFetch assertions (method, path, body, error handling)

Implementation steps

  1. Explore: re-open usePackages.ts — replicate the existing $customFetch+status ref pattern (lines 55–73) and the $mixpanel?.track(...) pattern (lines 258–262). Confirm the invoices route under features/subscriptions/invoices/.
  2. Red: update PackageInfoComponent.spec.ts to assert $customFetch called with correct method/path/body and that a rejected initiate fires the error toast + self_subs_initiation_failed. Run → fail.
  3. Implement: real useInitiateSelfSubs body; redirect to payment_link; post-return navigation to the invoices page + billing-info refetch; Mixpanel tracks at the right lifecycle points. Keep the renewal branch guarded (Q11).
  4. Green: pnpm test features/subscriptions/.
  5. Quality gate: pnpm type-check && pnpm lint && pnpm build.

Acceptance criteria

  • Initiate (New Sub / Upgrade) calls real POST /self_subscriptions and redirects to returned payment_link (SS-S02/AC-2, SS-S04/AC-1)
  • Renewal initiate stays behind a stub/guard until Q11 (renewal billing_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

DisciplineDays
Frontend1
Backend
QA0.5
Total1.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-checkout contract. 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

StoryReason
SS-S05 — Pending SO Warning ModalDescoped 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).