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>
This commit is contained in:
Grant
2026-06-03 21:23:09 -05:00
parent fea6995192
commit 4cde540b60
7 changed files with 414 additions and 23 deletions
+13 -1
View File
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
'0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
'',
'0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
'',
'0.2.0:49 — **Zaprite saved-profile capture: full diagnostic logging + reconciler path.** Sandbox testing of `:47` revealed five recurring subscriptions all settled successfully but with NULL `zaprite_payment_profile_id` — even though Zaprite confirmed the saved card on the contact. Two root causes addressed: (1) `capture_zaprite_payment_profile` had six different early-return-Ok branches (no provider, not Zaprite, downcast fail, no contact_id, no profiles array, no matching profile) that ALL silently returned with no logging, so there was no way to know which branch fired. Every branch now emits a `tracing::info!` or `tracing::warn!` explaining what it found, including a sample of the profiles\' `sourceOrder.externalUniqId` values when no match is found (to detect the timing race where Zaprite\'s profile-attach lags the order.paid webhook). (2) The polling reconciler (which catches missed webhook deliveries) called `issue_license_for_invoice` to recover the license + subscription, but never called `on_invoice_settled` — so a recurring sub created via the reconciler path NEVER got its Zaprite profile captured even though the saved profile was sitting on Zaprite\'s contact. Fixed in `reconcile.rs::ensure_license`: now invokes `on_invoice_settled` after license issuance (and on the idempotency early-return, in case a prior license-exists run missed the hook). The hook is itself idempotent and a no-op for BTCPay subs, so this is safe to call from both webhook and reconciler paths. Together these mean: even if your Zaprite webhook never delivers, the reconciler will pick up the slack within ~60 seconds AND capture the saved profile so auto-charge still works on the next renewal cycle.',
'',
'0.2.0:48 — **Thank-you page copy is now provider-aware.** The `/thank-you` landing page (where buyers wait while their license is signed) hardcoded "Your Bitcoin payment was received" + "Lightning settles in seconds; on-chain typically settles in 1020 minutes" — true for BTCPay-routed purchases, awkward for Zaprite-routed card payments where the buyer never touched Bitcoin. Fixed in `api/mod.rs::thank_you`: read `SETTING_ACTIVE_PROVIDER`, branch the lede copy on it. For Zaprite: "Your payment was received. Card payments confirm in seconds; Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically settles in 1020 minutes." For BTCPay (and the unconfigured fallback): unchanged Bitcoin-only copy. Also passed the provider kind into the polling JS so the running-status copy (`waitingCopy()`) makes the same distinction at every elapsed-time threshold (2 min, 10 min, slow-block). When the planned multi-provider work lands, this lookup will switch from the singleton setting to the invoice\'s own `payment_provider_id` so the copy matches the rail that actually settled THIS purchase rather than whatever\'s currently active on the daemon.',
'',
'0.2.0:47 — **Zaprite recurring purchases now create the contact upfront.** First-time test purchase against a live Zaprite sandbox surfaced the gap: when the order body has `allowSavePaymentProfile: true`, Zaprite\'s API requires an explicit `contactId` and returns `400 contactId is required when allowSavePaymentProfile is true` if you only pass `customerData: { email }`. Their llms.txt docs say contactId is optional in that case; the API itself disagrees, and the API is the source of truth. Fixed in `payment/zaprite/provider.rs`: when about to send `allowSavePaymentProfile: true`, first call a new `client.create_contact(email, name)` helper (`POST /v1/contacts`), then pass the returned id as `contactId` on the subsequent `create_order` call. Three handling paths: (1) recurring + buyer_email present → create contact + save profile, the happy path; (2) recurring + buyer_email MISSING → degrade to one-shot for THIS cycle (buyer gets a license, renewals fall back to manual-pay, warn-logged); (3) non-recurring → unchanged (no contact created, customerData only). Known minor: Zaprite\'s duplicate-email behavior on `POST /v1/contacts` is undocumented, so the same buyer purchasing recurring twice may end up with duplicate contacts in the operator\'s Zaprite dashboard until the multi-provider work introduces a Keysat-side dedup cache.',
'',
'0.2.0:46 — **Provider create-invoice failures now surface the underlying cause.** When `provider.create_invoice` failed (Zaprite or BTCPay rejection, network error, currency validation), the buy page rendered only "payment provider create-invoice failed: ZapriteProvider.create_invoice" — the outermost `context()` wrapper — and the actual cause (HTTP status + response body from the upstream) was never logged anywhere either. The trait method returned the anyhow error; only the tower trace layer fired, and it only sees the HTTP status code, not the body. Fixed in `api/purchase.rs`: switch user-facing format from `{e}` to `{e:#}` so the full anyhow chain shows up on the buy page, and add an explicit `tracing::error!` before returning so the same chain lands in daemon logs. Operator-visible: failed checkouts now actually tell you what went wrong ("Zaprite create_order returned HTTP 400: missing payment_methods", etc.) without log-spelunking.',
'',
'0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
'',
'0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
@@ -525,7 +537,7 @@ const ROUTINE_NOTES = [
].join('\n\n')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:45',
version: '0.2.0:51',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under