241478af95247b4e2bf6af36deb9d822cd92e07b
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
04e0dcd591 |
WIP — merchant profile foundation (multi-provider payment model, part 1)
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f8affdb11f |
Tier upgrades Phase 2 — quote logic + apply step
Builds on
|
||
|
|
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.
|
||
|
|
eb885502ba |
Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback
src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins via the settings table override the chain — used by tests for deterministic conversions and by operators during maintenance windows. Admin endpoints: - GET /v1/admin/rates: cache snapshot - POST /v1/admin/rates/refresh: force re-fetch (audit-logged) Two new tests (network-free, manual-pin path): - rate_cache_honors_manual_pin_from_settings - admin_rates_endpoint_reflects_manual_pin Test count: 36 (was 34). |
||
|
|
d827b1aaab |
Opt-in community analytics + admin UI surface
Closes the last T2 plan item. Off by default; toggling on requires the operator to confirm a collector URL (an empty URL is "armed but silent"). The toggle lives on the admin Overview page next to the public-key card — the right place for a privacy-affecting choice since it's where operators actually live. What's sent (per the in-card "Show me exactly what gets sent" disclosure, and pinned by the test): - install_uuid: random UUIDv4 generated on first opt-in. NOT derived from operator_name, store id, public URL, or any other identifier. Wipeable via the Reset button. - daemon_version (CARGO_PKG_VERSION). - tier (creator/pro/patron/unlicensed) — the same string the admin tier endpoint already exposes. - counts: products, active_licenses, settled_invoices — each floored to the nearest 5 (anti-fingerprinting; an exact license count uniquely identifies an operator over time). - uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact). What's NOT sent (test asserts none of these strings appear in the preview heartbeat): operator_name, public_url, store_id, api_key, buyer_email, btcpay_url. Also no product/policy slugs or names, no license/invoice ids, no fingerprints, no webhook secrets. Backend: - src/analytics.rs — heartbeat builder, opt-in check, daily background tick (5min initial grace period after boot). - src/api/community.rs — GET / POST / reset admin endpoints. - main.rs spawns the background tick unconditionally; the tick is a no-op if disabled OR no collector URL configured. Frontend (web/index.html, Overview page): - Toggle + collector URL input + privacy disclosure showing the EXACT JSON shape that would be sent (renders the live preview heartbeat from /v1/admin/community-analytics). - "Reset install_uuid" button so an operator who's been beaconing under one identifier can start fresh. Also includes the configureBtcpay.ts idempotency change from v0.1.0:46 (already committed; touched again here only because the diff includes the .ts file in the same dirty-tree push). Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract which seeds 23 licenses and verifies the heartbeat reports 20 — proves the floor-to-5 anti-fingerprinting is in effect). |
||
|
|
81066dfe62 |
Add API endpoint integration tests + library scaffolding
Closes the next-biggest test gap after migration tests. The daemon has
54+ HTTP endpoints, all previously untested at the request/response
level — same shape of blind spot that allowed the v0.1.0:39 migration
bug to ship.
What's new:
- src/lib.rs — exposes the daemon's modules as a library so integration
tests can import them (`pub mod api;`, etc.). Module source files are
unchanged; main.rs now imports via `use keysat::...` instead of
declaring `mod api;` directly. No runtime behaviour change in the
binary.
- tests/api.rs — 5 integration tests that drive real HTTP requests
through axum::Router::oneshot against a real SQLite tempfile pool
(same options as src/db/mod.rs::init):
1. health_endpoint_returns_200 — framework smoke test
2. admin_endpoint_rejects_missing_or_wrong_auth — 401 vs 403 paths
3. admin_creates_product_with_correct_token — full happy path
(auth → handler → DB insert → audit log → response)
4. validate_rejects_unsigned_garbage — early parse-fail surfaces
as `ok: false, reason: "bad_format"` (HTTP still 200)
5. validate_accepts_well_formed_license — issues a license via
repo, signs a matching LicensePayload with the daemon's
actual key, encodes to wire format, validates via the
endpoint, asserts ok=true plus populated metadata fields
Test count: 9 unit + 4 migrations + 5 API = 18 (was 13).
Cargo.toml dev-deps gain `tower = { version = "0.4", features = ["util"] }`
for ServiceExt::oneshot. The main `tower` dep is feature-minimal because
axum only needs a subset.
Out of scope (explicit follow-ups):
- Purchase happy path (needs a MockPaymentProvider implementing the
trait; ~250 LOC of mock + ~200 LOC of test).
- Webhook handler with idempotency assertions (same MockPaymentProvider
dependency).
- Tier-cap enforcement (mechanically simple; small follow-up PR).
- Discount-code atomic reserve race (better as a SQL-layer unit test
than an HTTP integration test).
- Rate-limiting (interacts with shared state; needs careful isolation).
- Cookie/session auth (already covered in session_layer.rs).
|