Skip to main content

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

AreaFE daysBE daysQA daysTotal
BE — flag round-trip verification (hub_core + hub_service)0.50.51.0
BE — Jurnal SI-fetch timeout (Decision 3)1.00.51.5
BE — auth allowlist on UpdateExtras (Decision 2, gated on OQ-2)1.00.51.5
FE — read billing-info flag + Download-SI button & download handling2.00.52.5
Grand total2.02.52.06.5

Confidence: medium. The behavior and all three backend contracts are fully specified and verified (GET /billings/info data.extras, downloads-jurnal {url|null}, downloads/:order_id PDF). The flag itself is a zero-code passthrough on organization_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

ActionFileWhat changes
extendhub_core/app/apps/billings/repositories/v2/organization_packages/update_extras_spec.rbexample: writing boolean is_download_order_si_jurnal persists it in extras
extendhub_service/spec/services/api/core/v1/billings/resources/billings_spec.rbassert data.extras.is_download_order_si_jurnal == true after set; absent when unset
(optional) createhub_core billings constants (match existing convention)IS_DOWNLOAD_ORDER_SI_JURNAL key constant if a constants home exists

Implementation steps

  1. Explore — open hub_core/app/apps/billings/repositories/v2/organization_packages/update_extras.rb + its _spec.rb; confirm package.extras[@key] = @value. Open hub_core/app/core/domains/builders/billings/billing_info.rb:36 to re-confirm extras is passed through wholesale.
  2. Red — in update_extras_spec.rb, add key: 'is_download_order_si_jurnal', value: true; assert package.reload.extras['is_download_order_si_jurnal'] == true. In billings_spec.rb, add the key to expected_output[:extras]. Run, confirm fail.
  3. Implement — no production change if passthrough holds (it does); add the optional constant only if the repo has a constants home.
  4. Greencd hub_core && bundle exec rspec app/apps/billings/repositories/v2/organization_packages/update_extras_spec.rb and cd hub_service && RAILS_ENV=test bundle exec rspec spec/services/api/core/v1/billings/resources/billings_spec.rb.
  5. Quality gatecd hub_core && brakeman --no-exit-on-warn --no-exit-on-error.

Acceptance criteria

  • Setting is_download_order_si_jurnal=true via UpdateExtras persists it in organization_packages.extras (hub_core spec green).
  • GET /billings/info returns data.extras.is_download_order_si_jurnal == true after 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

DisciplineDays
Frontend
Backend0.5
QA0.5
Total1.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

ActionFileWhat changes
extendhub_core/app/apps/billings/services/jurnal_apis.rbpass timeout: into get_sales_order / get_sales_invoice_url request calls
(verify)hub_core/app/core/domains/repositories/http/request_methods.rbconfirm get(...) forwards timeout; do not change its default
extendhub_core/app/apps/billings/repositories/v2/jurnal/get_sales_invoice_url_spec.rbadd a timeout/slow-response case asserting Failure; existing url:nil cases stay green

Implementation steps

  1. Explore — open hub_core/app/apps/billings/services/jurnal_apis.rb and hub_core/app/core/domains/repositories/http/request_methods.rb:71-79 (the Typhoeus::Request.new(..., timeout: timeout) site, default 0).
  2. Red — in get_sales_invoice_url_spec.rb, add a context stubbing a Typhoeus timeout (base it on the existing stub_jurnal_get_sales_order_* helpers); assert the repository returns Failure. Run, confirm fail.
  3. Implement — pass an explicit timeout constant from the two JurnalApis methods into the request call; leave request_methods.rb's default untouched.
  4. Greencd 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.
  5. Quality gatebrakeman --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 (url string) and url:null paths unchanged.
  • The shared RequestMethods default (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

DisciplineDays
Frontend
Backend1.0
QA0.5
Total1.5

Assumptions: timeout threaded from JurnalApis only (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

ActionFileWhat changes
extendhub_core/app/apps/billings/interactors/v2/organization_packages/update_extras.rbguarded-key rule rejecting rollout keys for non-super-admin callers
extendhub_core/app/apps/billings/interactors/v2/organization_packages/update_extras_spec.rbassert guarded key rejected for normal scopes, allowed for the privileged path
extendhub_service/app/services/api/core/v1/billings/resources/billings.rb(if needed) a super_admin-scoped write path for guarded keys

Implementation steps

  1. Explore — open update_extras.rb (interactor) and review the existing rule(:value). Check how super_admin scope is enforced elsewhere in billings.rb (e.g. the orders configs put '/:id' uses oauth2 :super_admin).
  2. Red — spec: normal scope writing is_download_order_si_jurnalFailure; privileged path → success; non-guarded keys (e.g. order_due_date) still writable by normal scope.
  3. Implement — add the allowlist/denylist; wire the privileged write path per the OQ-2 decision.
  4. Greencd hub_core && bundle exec rspec app/apps/billings/interactors/v2/organization_packages/update_extras_spec.rb.
  5. Quality gatebrakeman; request infosec review.

Acceptance criteria

  • Non-super-admin write of a guarded key via public PUT /billings/extras422.
  • 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

DisciplineDays
Frontend
Backend1.0
QA0.5
Total1.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.url is a non-empty string → window.open(url, '_blank') (open SI in a new tab).
  • data.url === null → fall back to GET /api/core/v1/billings/orders/downloads/:order_id and download the PDF (Order-Invoice-{transaction_number}.pdf).
  • HTTP error / timeout (422) → error toast, no fallback.

Implementation Plan

ActionFileWhat changes
extendfeatures/subscriptions/invoices/InvoicesListPage.vue [unverified — check repo]flag-gated "Download SI" action on paid rows + loading/disabled state + click handler
create / extendfeatures/subscriptions/invoices/useDocumentDownload.ts (or extend useInvoiceDownload.ts) [unverified — check repo]fetchSiUrl(orderId) + 3-branch handler (url → open tab; null → fallback PDF; error → toast)
extendbilling-info store / composable that already reads GET /billings/info [unverified — check repo]expose extras.is_download_order_si_jurnal (coalesce missing → false)
create / extendmatching *.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 (locate features/subscriptions/invoices/, match its component/spec naming + import alias) before writing code.

Implementation steps

  1. Explore the codebase — open features/subscriptions/invoices/InvoicesListPage.vue and the existing invoice-download composable; note how GET /billings/info is already read, how the action column renders per order_status, and the existing toast + blob-download helpers. Reuse those patterns (same import alias, same toast).
  2. Write failing tests (red) — in the matching *.spec.ts, assert: button hidden when flag off or status ≠ paid; visible when flag true and status paid; click with url string → window.open called; click with url:nulldownloads/:order_id called; click with 422 → toast shown, no fallback. Run the hub-chat test command; confirm they fail.
  3. Scaffold — add the "Download SI" action to the paid-order row (mock the two endpoints) with a loading/disabled state to prevent double-click.
  4. Wire state — read extras.is_download_order_si_jurnal from the billing-info store (coalesce missing → false); gate the button on flag === true && order.order_status === 'paid'.
  5. Implement behaviorfetchSiUrl(orderId) → branch: url → window.open(url,'_blank'); null → call the existing invoice-PDF download (downloads/:order_id); error → error toast.
  6. Go green — run the hub-chat test command until all pass.
  7. 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 === true and order_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 url string → opens the SI in a new tab (AC-003-3).
  • Click with url === null → downloads the existing invoice PDF via downloads/: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

DisciplineDays
Frontend2.0
Backend
QA0.5
Total2.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-jurnal and downloads/: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.extras shape. 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

StoryReason
US-002 — Download SO on pending ordersOut of scope per RFC §1 / 2026-06-30 scope clarification (this initiative is the Jurnal-SI flag only).