PayCraft Architecture
Provider-Agnostic Design
The core design principle of PayCraft: the app never talks to the payment provider directly.
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENT APP │
│ │
│ PayCraft.configure(provider = StripeProvider(...)) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ PayCraftPaywall │ │ PayCraftRestore │ │ PayCraftBanner │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬─────────┘ │
│ └───────────────────┬┘ │ │
│ ▼ │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ BillingManager (Interface) │ │
│ │ isPremium: StateFlow<Boolean> │ │
│ │ subscriptionStatus: StateFlow<SubscriptionStatus> │ │
│ │ logIn(email) · logOut() · refreshStatus() │ │
│ └──────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼─────────────── ──────────────────┐ │
│ │ PayCraftBillingManager (Implementation) │ │
│ │ Queries Supabase · Caches email · Manages state │ │
│ └──────────────────────────┬─────────────────────────────────┘ │
│ │ │
└─────────────────────────────┼───────────────────────────────────────┘
│ Supabase RPC calls
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SUPABASE (Source of Truth) │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │
│ │ subscriptions table │ │ is_premium(email) → boolean │ │
│ │ email provider plan│ │ get_subscription(email) → row │ │
│ │ status period_end │ └──────────────────────────────────┘ │
│ └──────────────────────┘ │
│ ▲ │
│ │ upsert via webhook │
│ ┌─────────┴──────────────────────────────────────────────────┐ │
│ │ Webhook Edge Functions │ │
│ │ ┌──────────── ─────┐ ┌──────────────────────────────┐ │ │
│ │ │ stripe-webhook │ │ razorpay-webhook │ │ │
│ │ │ (Deno/TS) │ │ (Deno/TS) │ │ │
│ │ └────────┬─────────┘ └────────────┬─────────────────┘ │ │
│ │ └────────────────┬──────────┘ │ │
│ │ ▼ │ │
│ │ subscription-handler.ts (shared upsert logic) │ │
│ └───────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
▲ ▲
│ webhook │ webhook
┌──────────┴─────────┐ ┌──────────┴──────────┐
│ Stripe │ │ Razorpay │
│ (payment events) │ │ (payment events) │
└────────────────────┘ └─────────────────────┘
Key Insight
The payment provider (Stripe, Razorpay, etc.) is only used for:
- Getting the checkout URL — where to send the user to pay
- Getting the management URL — where the user can cancel/update
Everything else — subscription status, premium checks, caching — goes through Supabase.
This means:
- Switching providers = updating
PayCraft.configure(), no client code changes - Provider outage ≠ app outage (status cached in Supabase)
- Multi-provider support is trivial (two webhooks, same table)
Component Breakdown
PayCraft (Singleton)
object PayCraft {
fun configure(builder: PayCraftConfigBuilder.() -> Unit)
fun checkout(plan: BillingPlan, email: String? = null) // opens checkout URL
fun manageSubscription(email: String) // opens management URL
}
Holds the configuration. Called once at app startup before Koin.
BillingManager (Interface)
interface BillingManager {
val isPremium: StateFlow<Boolean>
val subscriptionStatus: StateFlow<SubscriptionStatus>
val billingState: StateFlow<BillingState>
val userEmail: StateFlow<String?>
fun logIn(email: String) // restores purchase
fun logOut() // clears state
fun refreshStatus() // re-checks Supabase
}
The public API your app uses. Injected via Koin.
PayCraftBillingManager (Implementation)
- On
logIn(email): saves email to persistent storage, callsis_premium(email)RPC - On
isPremium = true: fetches fullSubscriptionStatusviaget_subscription(email) - Emits
BillingState:Loading → Free | Premium(status) | Error - On app restart: loads saved email, checks status automatically
PaymentProvider (Interface)
interface PaymentProvider {
val name: String
val webhookFunctionName: String
fun getCheckoutUrl(plan: BillingPlan, email: String? = null): String
fun getManageUrl(email: String): String?
}
Only the two URL-getters. Implemented per-provider.
Supabase Schema
CREATE TABLE public.subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL, -- user identifier
provider text NOT NULL, -- "stripe", "razorpay"
provider_customer_id text,
provider_subscription_id text,
plan text NOT NULL, -- plan ID from PayCraft.configure()
status text NOT NULL, -- "active", "canceled", "past_due", "trialing"
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean DEFAULT false,
trial_start timestamptz, -- v1.1 (migration 026) — NULL when no trial
trial_end timestamptz, -- v1.1 (migration 026) — NULL when no trial
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
One row per user. Email is the unique identifier (no Supabase Auth required).
is_premium() Logic
CREATE OR REPLACE FUNCTION is_premium(user_email text)
RETURNS boolean AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.subscriptions
WHERE email = lower(user_email)
AND status IN ('active', 'trialing')
AND current_period_end > now()
);
END;
$$ LANGUAGE plpgsql;
Simple, fast, cacheable. No JWTs, no user sessions required.
The status IN ('active', 'trialing') clause is intentional and was already
correct pre-v1.1 — trialing subscriptions ARE premium. current_period_end
during a trial equals trial_end, so the time gate also holds. No change was
needed to this RPC when trial support landed.
Trials (since v1.1)
Trial-product support follows the same architectural principle as the rest of
PayCraft: the app never talks to the provider directly. Trials are
configured at the provider (Stripe Price trial_period_days, Razorpay
subscription start_at) and surfaced to the client via the same webhook →
Supabase → RPC chain.
Three pieces
-
Configuration (one-time, per Price/Plan) — set during adoption:
- Stripe:
mcp__stripe__create_pricewithrecurring.trial_period_days(seepaycraft-adopt-stripe.mdstep 3A.3). - Razorpay: per-subscription
start_atat checkout time (seepaycraft-adopt-razorpay.mdstep 3B.2).
- Stripe:
-
Webhook mapping —
server/functions/_shared/subscription-handler.tsacceptstrialStart/trialEndon everySubscriptionEvent. The Stripe webhook (server/functions/stripe-webhook/index.ts) extractssub.trial_startandsub.trial_endfromcustomer.subscription.createdandcustomer.subscription.updatedevents. Result:subscriptions.trial_endis populated. -
Client surfacing —
PayCraftService.getSubscriptionreturns the trial columns (migration 026 extended the RPC's status filter to include'trialing').PayCraftBillingManager.applyPremiumResultbuilds aTrialInfo(endsAt, daysRemaining)via the purecomputeTrialInfo()helper and emits it insideBillingState.Premium.trial. Two parallelStateFlows onBillingManager(isInTrial,trialEndsAt) provide direct binding targets for consumer UIs that don't want towhenon the sealed state.
Eligibility (is_trial_eligible)
A second trial is impossible. is_trial_eligible(server_token) returns
NOT EXISTS (SELECT 1 FROM subscriptions WHERE email = $1 AND trial_end IS NOT NULL)
— if the user has ever had a trial recorded, they're disqualified server-side.
The paywall (via BillingManager.checkTrialEligibility()) suppresses the trial
CTA accordingly. Race window between trial start and webhook arrival is
~seconds; Stripe's own duplicate-subscription detection catches collisions at
the price level.
Resub protection (migration 027): is_trial_eligible depends on
trial_end persisting permanently. A naïve UPSERT on the email key would
clobber the historical trial_end when a user cancels and resubscribes with a
new provider_subscription_id — re-opening eligibility. The
subscriptions_preserve_trial_fields_trigger (BEFORE UPDATE) treats trial
fields as sticky: any UPDATE that tries to clear them (NEW=NULL ∧ OLD≠NULL)
falls back to the historical value. Legitimate Stripe trial extensions (NEW
non-null) are still honored.
BillingPlan.trialDays is a hint, not a contract
The consumer's BillingPlan(trialDays = 7) drives the paywall display
(the "Start 7-day free trial" chip and CTA) but does NOT enforce the trial
period. Stripe's trial_period_days on the Price is authoritative — if the
two diverge, the paywall says one thing and Stripe bills another. Keep them
aligned by always reconfiguring through /paycraft-adopt-stripe, which
writes both the Stripe Price config and the PAYCRAFT_PLAN_[i]_TRIAL_DAYS
.env entry consumed by the consumer's BillingPlan declaration.
Razorpay status
The Razorpay webhook handler (server/functions/razorpay-webhook/index.ts)
lives next to Stripe's. It handles subscription.activated,
subscription.charged (renewal), subscription.cancelled,
subscription.halted (past_due), and subscription.completed. Trial detection
follows Razorpay's "scheduled first invoice" convention: if
subscription.start_at > subscription.created_at, the window between them is
the trial — we map trial_start = created_at, trial_end = start_at.
Email resolution uses subscription.notes.paycraft_email, written by the
adopt-flow at subscription creation (consumer apps must include it in the
notes when calling Razorpay's subscription-create API). Without an email, the
shared handler falls back to updating by provider_subscription_id only —
fine for renewals/cancellations, but the initial activation won't be a full
upsert.
Dual-mode signature verification mirrors the Stripe pattern: the handler
tries RAZORPAY_TEST_WEBHOOK_SECRET first, then RAZORPAY_LIVE_WEBHOOK_SECRET,
and refuses any payload that neither verifies. Mode is also opportunistically
read from subscription.notes.paycraft_mode to skip a verification roundtrip.
Security Model
See SECURITY.md for details on:
- Webhook signature verification (Stripe HMAC-SHA256, Razorpay HMAC-SHA256)
- RLS policies (public read, service_role write)
- Key management (never hardcode, use secrets manager)
- Anon key safety (is_premium is public read — cannot be abused)