Task Breakdown — Sales Invoice + Jurnal Integration (is_download_order_si_jurnal)
RFC: sales-invoice-jurnal.md · Slicing: by work area (BE + FE) · Blocked tasks: included
Definition of done (RFC §1 Success Criteria): with is_download_order_si_jurnal on for an org, a paid self top-up order shows a "Download SI" button; click → fetch the Jurnal SI URL; url → open in a new tab; url:null → fall back to the existing invoice PDF; error → toast. Default OFF for all orgs (flag absent).
Effort Summary
| Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| BE — flag round-trip verification (hub_core + hub_service) | — | 0.5 | 0.5 | 1.0 |
| BE — Jurnal SI-fetch timeout (Decision 3) | — | 1.0 | 0.5 | 1.5 |
BE — auth allowlist on UpdateExtras (Decision 2, gated on OQ-2) | — | 1.0 | 0.5 | 1.5 |
| FE — read billing-info flag + Download-SI button & download handling | 2.0 | — | 0.5 | 2.5 |
| Grand total | 2.0 | 2.5 | 2.0 | 6.5 |
Confidence: medium. The behavior and all three backend contracts are fully specified and verified (
GET /billings/infodata.extras,downloads-jurnal{url|null},downloads/:order_idPDF). The flag itself is a zero-code passthrough onorganization_packages.extras(so the BE "build" is verification + optional hardening, not new endpoints/migrations). Two movers: OQ-2 (if Ops-only gating is chosen, Task 3 is real; if self-enable is accepted, Task 3 drops) and the FE half (no Figma yet, hub-chat not grounded here → FE paths are[unverified — check repo]).
BE — actionable now
Task 1: [BE] Lock the is_download_order_si_jurnal flag round-trip (US-001)
An Ops user can set a per-org flag that the client app reads, turning the "Download SI" capability on or off for that org.
Status: ✅ Actionable
What to build
Regression specs proving the flag round-trips with no production read-path code (Decision 1: extras is already passed through by BillingInfo). Setting is_download_order_si_jurnal via the existing UpdateExtras persists it in organization_packages.extras, and GET /billings/info returns it under data.extras. Optionally add a shared constant for the key name to avoid string drift.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/apps/billings/repositories/v2/organization_packages/update_extras_spec.rb | example: writing boolean is_download_order_si_jurnal persists it in extras |
| extend | hub_service/spec/services/api/core/v1/billings/resources/billings_spec.rb | assert data.extras.is_download_order_si_jurnal == true after set; absent when unset |
| (optional) create | hub_core billings constants (match existing convention) | IS_DOWNLOAD_ORDER_SI_JURNAL key constant if a constants home exists |
Implementation steps
- Explore — open
hub_core/app/apps/billings/repositories/v2/organization_packages/update_extras.rb+ its_spec.rb; confirmpackage.extras[@key] = @value. Openhub_core/app/core/domains/builders/billings/billing_info.rb:36to re-confirmextrasis passed through wholesale. - Red — in
update_extras_spec.rb, addkey: 'is_download_order_si_jurnal', value: true; assertpackage.reload.extras['is_download_order_si_jurnal'] == true. Inbillings_spec.rb, add the key toexpected_output[:extras]. Run, confirm fail. - Implement — no production change if passthrough holds (it does); add the optional constant only if the repo has a constants home.
- Green —
cd hub_core && bundle exec rspec app/apps/billings/repositories/v2/organization_packages/update_extras_spec.rbandcd hub_service && RAILS_ENV=test bundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb. - Quality gate —
cd hub_core && brakeman --no-exit-on-warn --no-exit-on-error.
Acceptance criteria
- Setting
is_download_order_si_jurnal=trueviaUpdateExtraspersists it inorganization_packages.extras(hub_core spec green). -
GET /billings/inforeturnsdata.extras.is_download_order_si_jurnal == trueafter set; key absent when never set (hub_service spec green). - No regression in existing
billings_spec/update_extras_spec.
Test strategy
Pure rspec. hub_core: assert package.reload.extras contains the boolean. hub_service: extend expected_output and reuse the expect(response_as_json).to include({ status:'success', data: expected_output }.as_json) assertion already in billings_spec.rb.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: passthrough requires zero read-path code (verified); work is two specs + optional constant.
Run to verify
cd hub_core && bundle exec rspec app/apps/billings/repositories/v2/organization_packages/update_extras_spec.rb
cd ../hub_service && RAILS_ENV=test bundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb
Depends on
- None.
Task 2: [BE] Bound the Jurnal SI-fetch with a timeout (US-003, Decision 3)
When a client downloads the Sales Invoice, a slow or unreachable Jurnal API fails fast with a clean error instead of hanging the request.
Status: ✅ Actionable
What to build
Thread an explicit timeout (proposed 5s total budget, ≤~2.5s/call) into the two Jurnal calls behind downloads-jurnal. Today Repositories::Http::RequestMethods defaults to Typhoeus timeout: 0 (no timeout). Pass the timeout from JurnalApis without changing the shared default, so a slow Jurnal response becomes a Failure (→ 422 → FE toast) rather than a hang.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/apps/billings/services/jurnal_apis.rb | pass timeout: into get_sales_order / get_sales_invoice_url request calls |
| (verify) | hub_core/app/core/domains/repositories/http/request_methods.rb | confirm get(...) forwards timeout; do not change its default |
| extend | hub_core/app/apps/billings/repositories/v2/jurnal/get_sales_invoice_url_spec.rb | add a timeout/slow-response case asserting Failure; existing url:nil cases stay green |
Implementation steps
- Explore — open
hub_core/app/apps/billings/services/jurnal_apis.rbandhub_core/app/core/domains/repositories/http/request_methods.rb:71-79(theTyphoeus::Request.new(..., timeout: timeout)site, default0). - Red — in
get_sales_invoice_url_spec.rb, add a context stubbing a Typhoeus timeout (base it on the existingstub_jurnal_get_sales_order_*helpers); assert the repository returnsFailure. Run, confirm fail. - Implement — pass an explicit
timeoutconstant from the twoJurnalApismethods into the request call; leaverequest_methods.rb's default untouched. - Green —
cd hub_core && bundle exec rspec app/apps/billings/repositories/v2/jurnal/get_sales_invoice_url_spec.rb; confirm happy-path +url:nil(not_found/without_invoices) cases still pass. - Quality gate —
brakeman --no-exit-on-warn --no-exit-on-error.
Acceptance criteria
- A stubbed slow/timed-out Jurnal call returns
Failure(not a hang) within the budget. - Happy path (
urlstring) andurl:nullpaths unchanged. - The shared
RequestMethodsdefault (timeout: 0) is unchanged for other callers.
Test strategy
rspec stubbing a Typhoeus timeout via the existing stub_jurnal_* helpers; assert result.failure?. Key assertion: timeout surfaces as Failure, leaving happy + url:nil branches intact.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: timeout threaded from
JurnalApisonly (shared HTTP default untouched); value 5s confirmed via OQ-5.
Run to verify
cd hub_core && bundle exec rspec app/apps/billings/repositories/v2/jurnal/get_sales_invoice_url_spec.rb
Depends on
- OQ-5 (confirm the 5s value) — non-blocking; can ship with a sensible default and tune.
BE — gated on OQ-2
Task 3: [BE] Key allowlist on UpdateExtras so the flag isn't org-self-writable (Decision 2)
Only Ops/super_admin (not any org admin) can flip the rollout flag, so the feature rolls out under control.
Status: 🚫 Blocked — decision-gated on OQ-2 + infosec. PUT /billings/extras is currently scoped admin,owner,supervisor,agent,member, so any org user can self-set this flag. This task exists only if OQ-2 resolves to Option B (gate the write); if Option C (accept self-enable) is chosen, drop it. Unblock by: product + infosec deciding OQ-2 = Option B.
What to build
A key allowlist in UpdateExtras so rollout-controlling keys (incl. is_download_order_si_jurnal) cannot be written through the public PUT /billings/extras, plus an Ops/super_admin path to set them. Exact shape pending the OQ-2 decision.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/apps/billings/interactors/v2/organization_packages/update_extras.rb | guarded-key rule rejecting rollout keys for non-super-admin callers |
| extend | hub_core/app/apps/billings/interactors/v2/organization_packages/update_extras_spec.rb | assert guarded key rejected for normal scopes, allowed for the privileged path |
| extend | hub_service/app/services/api/core/v1/billings/resources/billings.rb | (if needed) a super_admin-scoped write path for guarded keys |
Implementation steps
- Explore — open
update_extras.rb(interactor) and review the existingrule(:value). Check howsuper_adminscope is enforced elsewhere inbillings.rb(e.g. the ordersconfigsput '/:id'usesoauth2 :super_admin). - Red — spec: normal scope writing
is_download_order_si_jurnal→Failure; privileged path → success; non-guarded keys (e.g.order_due_date) still writable by normal scope. - Implement — add the allowlist/denylist; wire the privileged write path per the OQ-2 decision.
- Green —
cd hub_core && bundle exec rspec app/apps/billings/interactors/v2/organization_packages/update_extras_spec.rb. - Quality gate —
brakeman; request infosec review.
Acceptance criteria
- Non-super-admin write of a guarded key via public
PUT /billings/extras→422. - Privileged path can set the flag.
- Existing non-guarded extras writes unaffected.
- Infosec sign-off recorded.
Test strategy
rspec on UpdateExtras asserting guarded-key rejection per scope; request spec on the privileged path.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: allowlist in
UpdateExtras(not a whole new endpoint); shape may change once OQ-2 lands.
Run to verify
cd hub_core && bundle exec rspec app/apps/billings/interactors/v2/organization_packages/update_extras_spec.rb
Depends on
- External: OQ-2 decision (Option B) + infosec — hard gate.
FE
Task 4: [FE] Read billing-info flag + Download-SI button & download handling (US-003)
A client viewing a paid self top-up order can click "Download SI" to open their Jurnal Sales Invoice in a new tab; if Jurnal has no SI for that order, the existing invoice PDF downloads instead.
Status: ✅ Actionable (logic fully specified; final button styling/placement awaits design — see Design reference)
Design reference: n/a — design pending (RFC OQ-1; PRD "Figma: TBD — pending design") · Design QA: TBD — build against the existing invoice-row action pattern; design-QA the placement when Figma lands.
What to build
On the self top-up invoices list (InvoicesListPage.vue): read data.extras.is_download_order_si_jurnal from GET /api/core/v1/billings/info. For orders with order_status === 'paid', render a "Download SI" action only when the flag is === true (missing/any-non-true = OFF). On click, call GET /api/core/v1/billings/orders/downloads-jurnal?order_id=<id> and branch on the response:
data.urlis a non-empty string →window.open(url, '_blank')(open SI in a new tab).data.url === null→ fall back toGET /api/core/v1/billings/orders/downloads/:order_idand download the PDF (Order-Invoice-{transaction_number}.pdf).- HTTP error / timeout (
422) → error toast, no fallback.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/subscriptions/invoices/InvoicesListPage.vue [unverified — check repo] | flag-gated "Download SI" action on paid rows + loading/disabled state + click handler |
| create / extend | features/subscriptions/invoices/useDocumentDownload.ts (or extend useInvoiceDownload.ts) [unverified — check repo] | fetchSiUrl(orderId) + 3-branch handler (url → open tab; null → fallback PDF; error → toast) |
| extend | billing-info store / composable that already reads GET /billings/info [unverified — check repo] | expose extras.is_download_order_si_jurnal (coalesce missing → false) |
| create / extend | matching *.spec.ts [unverified — check repo] | button visibility (flag × status) + the three click outcomes |
File path rule: hub-chat is not checked out in this grounding, so every path is
[unverified — check repo]. Confirm against the real repo (locatefeatures/subscriptions/invoices/, match its component/spec naming + import alias) before writing code.
Implementation steps
- Explore the codebase — open
features/subscriptions/invoices/InvoicesListPage.vueand the existing invoice-download composable; note howGET /billings/infois already read, how the action column renders perorder_status, and the existing toast + blob-download helpers. Reuse those patterns (same import alias, same toast). - Write failing tests (red) — in the matching
*.spec.ts, assert: button hidden when flag off or status ≠paid; visible when flagtrueand statuspaid; click withurlstring →window.opencalled; click withurl:null→downloads/:order_idcalled; click with422→ toast shown, no fallback. Run the hub-chat test command; confirm they fail. - Scaffold — add the "Download SI" action to the paid-order row (mock the two endpoints) with a loading/disabled state to prevent double-click.
- Wire state — read
extras.is_download_order_si_jurnalfrom the billing-info store (coalesce missing →false); gate the button onflag === true && order.order_status === 'paid'. - Implement behavior —
fetchSiUrl(orderId)→ branch: url →window.open(url,'_blank'); null → call the existing invoice-PDF download (downloads/:order_id); error → error toast. - Go green — run the hub-chat test command until all pass.
- Quality gate — hub-chat lint + build; design-QA the placement once Figma exists.
Acceptance criteria
- "Download SI" visible iff
data.extras.is_download_order_si_jurnal === trueandorder_status === 'paid'(US-003/AC-003-1, AC-003-2). - Flag off or absent → no button; existing invoice behavior unchanged (AC-003-2).
- Click with
urlstring → opens the SI in a new tab (AC-003-3). - Click with
url === null→ downloads the existing invoice PDF viadownloads/:order_id(Decision 5 / Jurnal-miss fallback). - Click with
422/timeout → error toast, no fallback (AC-003-6). - Button shows a loading spinner and is disabled while fetching (AC-003-4).
Test strategy
Component/unit tests mocking three calls: billings/info (flag on/off/absent), downloads-jurnal (url string / url:null), and downloads/:order_id. Key assertions: the gating predicate (flag === true && status === 'paid') and the three click branches (open-tab / fallback-PDF / toast). Mock window.open and the blob-download helper; assert which was called per branch.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2.0 |
| Backend | — |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses the existing invoice-download composable, toast, and billing-info store (no new infra); all three backend endpoints exist as-is. Re-estimate if design adds a confirmation modal or a detail-modal surface (PRD OQ#4).
Run to verify
# hub-chat repo — confirm the exact command from its package.json (e.g. pnpm test / pnpm vitest run)
# pnpm test -- features/subscriptions/invoices
Depends on
- Task 1 (flag contract) — provides
data.extras.is_download_order_si_jurnal. - Reused endpoints
downloads-jurnalanddownloads/:order_id(exist as-is).
Ordering rationale
- Task 1 → Task 2 first (both actionable, no external gate). Task 1 nails the flag contract the FE depends on; Task 2 removes the one real availability risk (unbounded Jurnal call). ≈ 2.5 days, shippable now.
- Task 4 (FE) depends on Task 1's contract but can start in parallel against the documented
data.extrasshape. It's the user-visible payload; final styling waits on design (OQ-1) but the logic is buildable now. - Task 3 is decision-gated, not effort-gated — push OQ-2 + infosec to a decision rather than start coding. On the critical path for broad rollout (controlled per-CID enablement); drops entirely if Option C is accepted.
- Critical path to a usable feature: Task 1 (contract) → Task 4 (FE button) ≈ 3.5 days; OQ-2/infosec in parallel decides whether Task 3 is needed before broad rollout.
Skipped stories
| Story | Reason |
|---|---|
| US-002 — Download SO on pending orders | Out of scope per RFC §1 / 2026-06-30 scope clarification (this initiative is the Jurnal-SI flag only). |