Commit Graph

9 Commits

Author SHA1 Message Date
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.

migrations/0021_invoice_provider_link.sql (new)
  Adds invoices.payment_provider_id (nullable FK), backfills existing
  pending/settled rows to the earliest-connected provider on the default
  profile. Additive — no drops, no removals. Companion to 0020 from the
  foundation commit.

models.rs
  Invoice gains payment_provider_id: Option<String>.

db/repo.rs
  row_to_invoice reads the new column. All three invoice SELECTs include
  it. create_invoice + create_invoice_with_currency take a new optional
  payment_provider_id parameter and persist it on INSERT.

subscriptions.rs
  Subscription struct gains merchant_profile_id + payment_provider_id
  (snapshotted on create). SUB_COLS + row_to_subscription + the manual
  SELECT in find_lapsing_subscriptions all updated. create_subscription
  accepts both new fields and writes them on the INSERT row.

  renew_one — reads the sub's payment_provider_id snapshot and resolves
  the provider via state.payment_provider_by_id(). Falls back to the
  legacy state.payment_provider() for any subs created pre-:52 that
  the migration backfill missed.

  capture_zaprite_payment_profile — uses the INVOICE's provider, not
  "the active one." Saved-profile ids are scoped per Zaprite org; using
  the wrong provider would fail the lookup.

  try_auto_charge_zaprite — uses the sub's snapshotted provider (same
  rationale).

reconcile.rs
  Per-invoice provider lookup. Each pending invoice is reconciled
  against state.payment_provider_by_id(inv.payment_provider_id), with
  graceful fallback for NULL provider ids. No more single-global-
  provider assumption.

tipping.rs
  Tip pay-out uses the provider that settled the license's purchase
  invoice (joined via licenses.invoice_id). Same rationale as the
  capture hook — the tip needs to draw from the right LN node.

api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
  Tier-change invoices ride on existing licenses. The right provider
  is whichever the license's subscription is snapshotted to (so the
  proration charge settles to the same merchant identity that collects
  renewal fees). Falls back to the invoice's recorded provider, then
  the legacy default, for licenses with no subscription or pre-
  snapshot rows.

api/purchase.rs
  StartPurchaseReq gains an optional `rail` field
  ("lightning"/"onchain"/"card") for the future buy-page multi-rail
  picker. When omitted (today's behavior), the daemon picks the first
  rail the product's merchant profile exposes — which is correct for
  single-provider operators AND back-compat for any pre-:52 client
  not yet sending the field.

  Provider resolution: product → merchant_profile → rail →
  resolve_provider_for_profile_rail. The redirect_url defaults to the
  profile's post_purchase_redirect_url (with {invoice_id} substitution)
  if set, else Keysat's own /thank-you. New invoices carry their
  provider's id via the new create_invoice_with_currency parameter.

api/webhook.rs
  issue_license_for_invoice now passes snapshot fields when calling
  subscriptions::create_subscription — both merchant_profile_id (from
  product lookup) and payment_provider_id (from the invoice row).

main.rs
  Replaces the legacy "active provider preference" boot loader with a
  default-profile-first-provider warm-up. The legacy state.payment
  singleton stays populated for back-compat with call sites that
  haven't yet migrated to the on-demand resolution path. Pre-migration
  fallback to the old singleton-config loaders preserved so the
  daemon still boots cleanly on a DB that hasn't run 0020 yet.

Remaining for part 3:
  - BTCPay + Zaprite connect flows take merchant_profile_id and
    INSERT into payment_providers (currently still write to the
    dropped singleton tables, broken post-migration).
  - api/payment_provider.rs activate endpoint becomes irrelevant in
    the new model — repurpose as list-providers, or delete.
  - Thank-you page (api/mod.rs) provider-kind lookup ports to the
    invoice's recorded provider.
  - Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
  - Admin UI for Merchant Profiles + product picker + buy-page brand
    block + rail picker.
  - Tier-cap wire-up for unlimited_merchant_profiles entitlement.
  - Version bump to :52 + release notes.

Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:26:22 -05:00
Grant 4cde540b60 v0.2.0:51 — Zaprite recurring polish from sandbox testing (:46-:51)
Six routine bumps land together, all driven by end-to-end sandbox testing
of the Zaprite recurring auto-charge path that shipped in :45:

:46  Provider create-invoice failures now surface the underlying cause.
     Switched user-facing format from `{e}` to `{e:#}` so the full anyhow
     chain reaches the buy page; added `tracing::error!` for symmetric
     daemon-log visibility. Without this, failed checkouts showed only
     "ZapriteProvider.create_invoice" with no clue what actually went wrong.

:47  Zaprite recurring purchases now create the contact upfront. Sandbox
     surfaced that `allowSavePaymentProfile: true` requires an explicit
     `contactId` on the order — passing only `customerData: { email }`
     returns 400. Added `client.create_contact(email, name)` + threaded
     the returned id as `contactId`. Graceful degradation: recurring +
     no buyer_email → one-shot mode with a warn log; renewals fall back
     to manual-pay.

:48  Thank-you page copy is now provider-aware. The wait-page lede
     hardcoded "Your Bitcoin payment was received" + Lightning/on-chain
     timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER
     and branches the copy + the JS polling-status text accordingly.

:49  Zaprite saved-profile capture: full diagnostic logging + reconciler
     path. Discovered five recurring subscriptions settled successfully
     but with NULL `zaprite_payment_profile_id`. Root cause: capture
     hook had six silent early-return paths, AND the reconciler (which
     catches missed webhooks) never called `on_invoice_settled` so subs
     created via that path never got their profile captured. Added warn
     logs on every early-return + wired capture into `reconcile.rs`'s
     post-license-issuance flow.

:50  Webhook event-type extraction probes multiple field names. Confirmed
     deliveries were arriving but all logged as "non-actionable event_type=
     " — Zaprite doesn't use the convention-suggested `event` field. Now
     probes `event` / `eventType` / `type` / `name`, first non-empty wins.
     Also widened the order-id probe to include `data.object.id`. On a
     miss, warn-logs the raw payload truncated to 2KB so the actual field
     name can be added to the probe list.

:51  Zaprite `order.change` event is now actionable. The :50 probe-fix
     surfaced that Zaprite's primary delivery shape is a generic
     `order.change` event that just says "something about this order
     changed" — the receiver has to look at `/data/status` to figure out
     what actually changed. They do NOT send the convention-suggested
     `order.paid` / `order.complete` events. Added an `order.change`
     match arm that branches on status (PAID/COMPLETE/OVERPAID →
     InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED →
     InvoiceInvalid, in-flight states → Other). End result: webhook-
     driven settles now flip subscriptions within seconds of Zaprite's
     callback instead of waiting ~45s for the reconciler.

Net effect of the batch: the recurring auto-charge flow is now validated
end-to-end against the Zaprite sandbox. Buyers paying with a card via
Stripe-backed Zaprite trigger contact + saved-profile creation, the
webhook fires `order.change` with status PAID, Keysat captures the
saved-profile id within seconds, and the renewal worker is wired to
auto-charge subsequent cycles. Manual-pay fallback intact for buyers
who decline save-card or pay via Bitcoin/Lightning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 21:23:09 -05:00
Grant fea6995192 v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
Two routine bumps land together in this release:

:44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and
hamburger-driven off-canvas drawer (≤720px) to the embedded
web/index.html so triage flows (status check, license lookup, revoke)
work from a phone. Tables now scroll horizontally inside their card,
tap targets bump to ~40px, stats grid collapses to 1-up, toolbar
inputs go full-width. Desktop layout unchanged. CSS + small JS toggle.

:45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap
the subscriptions.rs module comment promised but never delivered:
first-cycle invoices on recurring policies set allow_save_payment_profile,
the on-settle hook captures the resulting Zaprite paymentProfileId
into four new nullable columns on the subscriptions table (migration
0019, additive only), and the renewal worker calls
POST /v1/orders/charge against the saved profile instead of waiting
for manual pay. On charge failure (declined card, expired profile,
network) the worker logs + audits + falls through to the existing
subscription.renewal_pending event so the buyer still has a recovery
path. Two new operator webhook events: subscription.auto_charge_initiated
and subscription.auto_charge_failed. BTCPay subs and Zaprite subs
whose buyer paid with Bitcoin/Lightning or declined the save-card
prompt are untouched. NOT yet end-to-end tested against the Zaprite
sandbox — control flow follows api.zaprite.com/llms.txt but exact
failure-body shapes for declined cards aren't documented; sandbox
validation pass recommended before relying in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:20:53 -05:00
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
2026-05-11 08:45:25 -05:00
Grant 2fbd36fac6 P0 — recurring + trial + renewal-webhook + self-tier live refresh
Five fixes that were all blocking real-world use of the recurring
+ tier-upgrade features. All deeply related; bundling them into one
commit because they share data flow and would be silly to land
piecemeal.

1. Subscription row created on recurring purchase
   issue_license_for_invoice now calls
   subscriptions::create_subscription whenever the resolved policy
   has is_recurring=1. Previously the licenses row was inserted but
   no corresponding subscription, so the renewal worker never picked
   it up — buying a recurring policy was silently equivalent to a
   one-shot purchase. Idempotent against webhook re-delivery.

2. trial_days actually does something
   /v1/purchase short-circuits BEFORE pricing/discount logic when
   the chosen policy has is_recurring=1 AND trial_days > 0:
   synthesizes a free invoice via repo::create_free_invoice,
   issues the license inline with expires_at = now + trial_days,
   creates the subscription with next_renewal_at = trial_end so the
   renewal worker fires the FIRST paid invoice when the trial ends.
   Buyer pays nothing today. Discount codes are deliberately
   ignored on trial purchases (free + discount = no-op).

3. Trial license carries the TRIAL flag
   In the regular webhook issuance path, is_trial is now set
   whenever (policy.is_trial OR (is_recurring AND trial_days > 0)),
   so the signed payload's TRIAL bit reflects what the buyer is
   actually getting and SDK consumers can render
   "trial — N days remaining" correctly.

4. Renewal-pending webhook payload enriched
   subscription.renewal_pending now includes buyer_email (looked up
   from the license), product_id, policy_id, cycle_start_at,
   cycle_end_at, due_at, and is_first_paid_cycle. With these the
   operator's webhook receiver has everything it needs to render
   "your free trial is ending" vs "your monthly renewal is due"
   emails and forward the checkout_url to the buyer. Without this
   payload upgrade, renewal invoices were created server-side but
   no one knew about them.

5. Self-tier live refresh
   New license_self::refresh_self_tier_from_db re-reads the
   daemon's own license row from the local DB and rebuilds
   state.self_tier with LIVE entitlements (not the immutable
   signed-payload entitlements). Without this, an admin Change
   Tier on the daemon's own license never propagates — the
   running process keeps showing whatever tier was baked in at
   key-signing time, even though the DB row says otherwise.
   Wired to run:
   - Once at boot, immediately after check_at_boot (so any tier
     change between two daemon runs takes effect on next start)
   - Every hour thereafter (background task in main.rs)
   - On demand via POST /v1/admin/self-license/refresh, exposed
     for operators who don't want to wait for the next tick

   For master Keysat (the one selling licenses) the refresh
   query is local. Non-master operators in v0.3+ can extend this
   to call upstream `/v1/validate`. For v0.2.x, local-DB-only
   resolves your testing case (downgrade yourself, click refresh,
   sidebar updates, gate tests work).

6. Buy page CTA reflects trial
   When the selected tier has is_recurring=1 and trial_days > 0,
   the price card renders "FREE for N days" and the button reads
   "Start N-day free trial" instead of "Pay with Bitcoin". Buyer
   knows they aren't being charged today.

7. Invoice model gains listed_currency + listed_value
   Already in the DB schema (migration 0010); the Rust model just
   wasn't reading them. Needed by #1 to set the subscription's
   listed_value correctly for fiat-priced recurring policies.

Test count unchanged (77 passing). The recurring-tests-still-pass
proof point isn't the test suite (these are behavioral changes
above the renewal-worker tests' scope) — it's that the renewal
worker tests construct subscriptions explicitly and don't go
through the purchase path that was broken.
2026-05-09 13:52:47 -05:00
Grant c5d716a6d4 Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.

New endpoint:
- POST /v1/admin/licenses/:id/change-tier
  Body: { to_policy_slug, skip_payment: bool, reason?: string }

  skip_payment=true (comp upgrade / support fix-up): apply
  immediately, write a tier_changes row with proration=0 and
  invoice_id=NULL, fire the license.tier_changed webhook, audit-log
  with actor=admin_api_key.

  skip_payment=false: same as buyer's /v1/upgrade — create a
  provider invoice for the prorated charge, persist the local
  invoice + a tier_changes row tied to it, return the checkout URL.
  Operator forwards it to the buyer through whatever channel they
  use. Webhook applies on settle.

  Bypasses ladder rules entirely (sideways, perpetual downgrade,
  recurring → perpetual all OK). Same-product / different-policy /
  active-target checks still apply.

QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
  from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.

Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
  apply_pending_tier_changes(state, sub). This finds tier_changes
  rows for the sub's license where effective_at <= now AND
  invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
  scheduled comp/admin changes that haven't been applied yet).
  Each pending change is applied via apply_tier_change (which
  also rewrites the sub's policy_id / listed_value / period_days).
  After applying, the worker re-fetches the sub and prices the
  next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
  the cycle boundary (admin records "Pro → Standard at next
  renewal", the worker applies it, the new invoice bills at
  Standard's price).
- Idempotent: re-running the hook on a license already on the
  target tier finds zero pending rows (the policy_id != check
  filters them out).

Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
  flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
  buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
  attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
  the headline behavior: a pending downgrade tier_change with
  effective_at=now causes the next renewal to bill at the new
  (lower) tier's price, NOT the old one. Uses a CapturingProvider
  mock that stashes the last sat amount it saw so the assertion
  is on what the worker actually billed.
2026-05-08 20:12:44 -05:00
Grant 5d7f68fef8 Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)
Closes the recurring-subs feature loop: operators can cancel subs from
the admin UI, buyers can self-cancel by submitting their signed
license key. Cancellation is non-destructive — the license stays
valid through end-of-cycle, the renewal worker just stops creating
new invoices because its WHERE filter excludes status='cancelled'.

New API
- GET  /v1/admin/subscriptions             — list (filter: status=...)
- POST /v1/admin/subscriptions/:id/cancel  — operator cancel (audited)
- POST /v1/subscriptions/cancel            — buyer self-service; auth
                                             via license_key in body,
                                             verified by signature

Repo helpers (src/subscriptions.rs)
- get_subscription_by_id
- get_subscription_by_license_id  (1:1 unique on license_id, used by
                                   buyer self-service)
- list_subscriptions(status_filter, limit)
- cancel_subscription              (idempotent UPDATE, returns whether
                                    it actually transitioned)

Behavior details
- Both endpoints fire `subscription.cancelled` webhook with
  actor=admin/buyer so operators can distinguish self-service.
- Audit log differentiates by actor_kind: 'admin_api_key' vs
  'buyer_license_key'.
- Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe
  can't enumerate which licenses have active subs.
- Buyer endpoint returns 401 on revoked or suspended licenses too —
  same reason.
- Admin endpoint returns 200 with `{already: <prior_state>}` on
  re-cancel (idempotency); 404 on unknown sub.

Tests (+4, total now 57)
- admin_cancel_subscription_happy_path: full flow + DB invariants +
  audit row + idempotency
- admin_cancel_unknown_subscription_404s
- buyer_cancel_subscription_via_license_key: full flow + actor_kind
- buyer_cancel_rejects_garbage_key: 401 not 404

Admin UI for the cancel button + subscriptions tab lands in a
follow-up commit (kept this one to the API surface so it's reviewable
in isolation).
2026-05-08 17:53:42 -05:00
Grant c301eacfaa Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:

API
- Policy struct + repo gain is_recurring, renewal_period_days,
  grace_period_days, trial_days. RecurringConfig / RecurringUpdate
  helper structs keep create_policy / update_policy signatures
  manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
  rejects internally inconsistent combos (recurring=true with period=0,
  trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
  and Unlicensed get a 402 with upgrade_url. The gate fires on both
  create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
  trial_days so SDKs and the buy page can render cadence.

Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
  is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
  + grace period + trial days. Live enable/disable: the inputs gray
  out unless the box is ticked, and the custom-days input grays out
  unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
  policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
  trial badge so operators can see at a glance which policies renew.

Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
  every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
  price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
  trial_days so the JS price-update path keeps the cadence suffix
  in sync when the buyer clicks between tiers.

Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
  via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
  Pro 200 on same flip, name-only PATCH on already-recurring policy
  doesn't re-fire the gate after downgrade

Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
2026-05-08 17:47:55 -05:00
Grant 7007bf8204 Recurring subs Phase 2 — renewal worker (committed, not published)
Implements the renewal lifecycle from RECURRING_SUBSCRIPTIONS_DESIGN.md
phase 2. Operators don't see this yet (no admin UI); the worker
only acts on subscriptions that exist in the schema, and creating
subscription rows still requires direct DB insert. Phase 4 (admin
UI) wires the buyer-facing surface that creates them.

src/subscriptions.rs (new module, ~450 LOC):
- find_due_renewals: subs with status active|past_due whose
  next_renewal_at has passed and consecutive_failures < cap
- find_lapsing_subscriptions: past_due subs whose
  (next_renewal_at + grace_period_days) is in the past
- mark_lapsed / mark_active_after_settle / mark_renewal_failed:
  state-transition helpers
- create_subscription: atomic create-sub + first-cycle invoice
  (called by purchase flow when policy.is_recurring; not yet
  wired — that's a separate phase)
- on_invoice_settled: helper for webhook handler to flip a sub
  from past_due back to active and dispatch subscription.renewed
- find_subscription_for_invoice: lookup helper
- tick: 60s sweep, picks up to 25 due renewals + lapse sweep
- spawn: long-lived background task, mirrors webhooks::spawn_delivery_worker

Renewal flow per due sub:
  1. Convert listed_value to sats via rates::convert_to_sats
     (identity for SAT subs; live rate fetcher for USD/EUR — per
     MULTI_CURRENCY_DESIGN.md "USD-stable / re-quote each cycle"
     decision).
  2. Get the active payment provider, call create_invoice with
     the same trait surface used by one-shot purchases. Works
     against BTCPay or Zaprite or any future provider.
  3. Persist the local invoice row carrying the rate audit
     (listed_currency / listed_value / exchange_rate_centibps /
     exchange_rate_source). For SAT subs, rate fields are NULL
     (identity conversion isn't worth recording).
  4. Insert subscription_invoices linking the invoice to the sub
     with monotonic cycle_number.
  5. Update sub: status → past_due, next_renewal_at → end of new
     cycle, last_renewal_attempt_at → now.
  6. Dispatch subscription.renewal_pending webhook to the operator.

On settle (webhook handler): if the invoice is linked via
subscription_invoices, flip sub → active, reset
consecutive_failures to 0, dispatch subscription.renewed.

Failure path: increment consecutive_failures, push next_renewal_at
out by exponential backoff (5min → 30min → 2h → 6h → 12h, capped
at 5 failures ≈ 24h of retries before the worker stops trying).
Operator can see stuck subs via the upcoming admin UI; for now
they show up in the audit log via webhook deliveries.

Lapse path: separate sweep finds past_due subs whose
(next_renewal_at + policy.grace_period_days) is past now, flips
to lapsed, dispatches subscription.lapsed.

Wired into:
- src/lib.rs: pub mod subscriptions
- src/main.rs: subscriptions::spawn(state.clone()) alongside
  reconcile + webhooks + analytics
- src/api/webhook.rs: settle path now calls
  subscriptions::on_invoice_settled before license issuance —
  ordering matters because first-cycle subs create both a sub
  row AND a license; we want the sub state correct on the way
  to the license-issuance branch

Test: 7 integration tests in tests/subscriptions.rs. Drives the
worker against a MockProvider with fail-on-demand semantics:
- renewal_worker_creates_invoice_for_sat_priced_due_sub: SAT sub
  charges listed_value sats verbatim, no rate audit, sub goes
  active → past_due, subscription_invoices gets a new cycle row
- renewal_worker_requotes_rate_for_fiat_priced_sub: $25 USD at
  pinned $50k/BTC = exactly 50,000 sats; rate audit pinned on
  invoice; centibps encoded correctly
- renewal_worker_backs_off_on_failure: failed create_invoice →
  consecutive_failures = 1, no invoice created, sub → past_due
- renewal_worker_stops_retrying_at_max_failures: pre-set failures
  = MAX, tick is a no-op for that sub
- lapse_sweep_flips_past_due_after_grace: 15-day-old past_due
  with grace=7 → lapsed
- settle_webhook_flips_sub_back_to_active: tick creates renewal,
  simulate settle, on_invoice_settled flips sub back to active
- tick_is_no_op_when_nothing_due: empty fixture, tick is safe

Test count: 49 (was 42; +7).

NOT bumping version. The recurring-subs feature isn't operator-
visible until phases 4+5 (admin UI for creating recurring
policies + buy page rendering for "$25/month"). Schema is in,
worker runs, but nothing creates subs yet — so this commit
ships dormant.
2026-05-08 17:26:10 -05:00