From 8c4baccf6b33299430477aa3f242cae362b8b35d Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 12 Jun 2026 20:48:54 -0500 Subject: [PATCH] =?UTF-8?q?Bump=20to=200.2.0:53=20=E2=80=94=20ship=20the?= =?UTF-8?q?=20ambiguous-column=20purchase=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- startos/versions/v0.2.0.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index d17e3e9..b3d4eaf 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,8 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:53 — **Fix the ambiguous-column bug that broke every paid purchase on :52.** The `:52` merchant-profile model introduced `get_merchant_profile_for_product`, which selects the shared `MERCHANT_PROFILE_COLS` column list (a bare `id, name, …`) while JOINing `products` — but `products` also has an `id`, so SQLite raised `ambiguous column name: id` on every execution. That function runs on every purchase, so **every paid purchase on :52 returned HTTP 500**. Fixed in `db/repo.rs` by replacing the JOIN with an equivalent correlated subquery, keeping `merchant_profiles` the only table in FROM; NULL/missing `merchant_profile_id` behavior is unchanged (no row → caller falls back to the default profile). Also from the same verification pass: added `merchant_profile_provider_resolution_queries_round_trip` covering the previously untested runtime-prepared resolution / CRUD / preference queries, repaired three test call sites for the new `create_invoice` / `create_subscription` params, captured the response body in the `paid_purchase` status assertion, aligned the manifest license to `LicenseRef-Keysat-1.0`, and dropped an unused import. No schema change, no SDK change — straight drop-in over :52.', + '', '0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.', '', '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.', @@ -539,7 +541,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:52', + version: '0.2.0:53', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under