Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions docs/PAYMENT-DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ This guide helps template forkers deploy the payment integration system to their

## Status of the integration

| Component | Status |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Database schema (`payment_intents`, `payment_results`, `subscriptions`, `webhook_events`) | ✅ shipped in the monolithic migration |
| RLS policies (20+ across the 4 tables) | ✅ verified by `pnpm test:rls` |
| Service layer (`src/lib/payments/payment-service.ts`) | ✅ shipped |
| Components (`PaymentButton`, `PaymentStatusDisplay`, `SubscriptionManager`, etc.) | ✅ shipped (full 5-file pattern) |
| `/payment-demo` and `/payment-result` routes | ✅ shipped |
| **Outbound Edge Functions** (browser → provider checkout creation) | ⏳ **Partially shipped** — Phase 0a (Stripe one-off) ✅; rest tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100) |
| **Inbound Edge Functions** (provider webhooks → DB) | ✅ shipped (`stripe-webhook`, `paypal-webhook`, `send-payment-email`) |

**Operator note:** Phase 0a (Stripe one-off) ships in [PR #99 + #d0ba029] — clicking "Pay" on `/payment-demo` with the **Stripe tab** works once sandbox keys are configured. The **PayPal tab** and the subscription paths still fail with 404 until the remaining phases ship (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100), sub-issues [#102](https://github.com/TortoiseWolfe/ScriptHammer/issues/102)–[#106](https://github.com/TortoiseWolfe/ScriptHammer/issues/106)). Setting API keys alone is no longer enough on its own for everything — Phase 0a is the minimum for Stripe one-off payments.
| Component | Status |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database schema (`payment_intents`, `payment_results`, `subscriptions`, `webhook_events`) | ✅ shipped in the monolithic migration |
| RLS policies (20+ across the 4 tables) | ✅ verified by `pnpm test:rls` |
| Service layer (`src/lib/payments/payment-service.ts`) | ✅ shipped |
| Components (`PaymentButton`, `PaymentStatusDisplay`, `SubscriptionManager`, etc.) | ✅ shipped (full 5-file pattern) |
| `/payment-demo` and `/payment-result` routes | ✅ shipped |
| **Outbound Edge Functions** (browser → provider checkout creation) | ⏳ **Partially shipped** — Phase 0a (Stripe one-off) ✅ + Phase 0b (Stripe subscription) ✅; rest tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100) |
| **Inbound Edge Functions** (provider webhooks → DB) | ✅ shipped (`stripe-webhook`, `paypal-webhook`, `send-payment-email`) |

**Operator note:** Phases 0a + 0b ship **all Stripe paths** (one-off + subscription). Clicking "Pay" on `/payment-demo` with the **Stripe tab** works once sandbox keys are configured. The **PayPal tab** and subscription **cancel/resume** still fail with 404 until the remaining phases ship (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100), sub-issues [#103](https://github.com/TortoiseWolfe/ScriptHammer/issues/103)–[#106](https://github.com/TortoiseWolfe/ScriptHammer/issues/106)).

## Prerequisites

Expand Down Expand Up @@ -124,19 +124,17 @@ Each command prints the deployed URL (form: `https://<project-ref>.supabase.co/f

### 4.2 Outbound checkout creators

Phase 0a ✅ shipped (Stripe one-off):
Phase 0a + 0b ✅ shipped (Stripe one-off + subscription):

```bash
supabase functions deploy create-stripe-checkout
supabase functions deploy verify-stripe-session
supabase functions deploy create-stripe-subscription
```

Remaining (tracked in [#100](https://github.com/TortoiseWolfe/ScriptHammer/issues/100)):

```bash
# Phase 0b (#102) — Stripe subscription
supabase functions deploy create-stripe-subscription

# Phase 0c (#103) — PayPal one-off
supabase functions deploy create-paypal-order
supabase functions deploy capture-paypal-order
Expand All @@ -149,6 +147,8 @@ supabase functions deploy cancel-subscription
supabase functions deploy resume-subscription
```

**Subscription operator note:** Phase 0b assumes you've created a recurring `Price` object in the Stripe dashboard ahead of time. The `price_id` (e.g. `price_1AbCdEf...`) gets passed to `create-stripe-subscription` by the browser. The `subscriptions` table row is **inserted by the existing `stripe-webhook` handler** when `customer.subscription.created` fires — not by `create-stripe-subscription` itself. The two are paired: the subscription session sets `subscription_data.metadata.template_user_id` so the webhook can satisfy the table's NOT NULL constraint. Re-deploy `stripe-webhook` after Phase 0b lands (the handler logic was updated in the same PR).

Until all 8 ship, browser code that calls a non-shipped function will fail at the fetch step with a 404. **Phase 0a alone unlocks the one-off Stripe checkout flow** — `/payment-demo` Stripe tab works end-to-end once sandbox keys are configured.

## Step 5 — Register webhooks in provider dashboards
Expand Down
14 changes: 14 additions & 0 deletions supabase/functions/create-stripe-subscription/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"tasks": {
"serve": "deno run --allow-net --allow-env --allow-read index.ts",
"test": "deno test --allow-net --allow-env --allow-read index.test.ts"
},
"compilerOptions": {
"lib": ["deno.window"],
"strict": true
},
"imports": {
"stripe": "https://esm.sh/stripe@14.21.0?target=deno",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2"
}
}
105 changes: 105 additions & 0 deletions supabase/functions/create-stripe-subscription/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Deno tests for create-stripe-subscription.
*
* Contract tests for the HTTP-layer validation. End-to-end happy path
* is verified by tests/e2e/payment/02-paypal-subscription.spec.ts (the
* file covers both Stripe and PayPal subscription paths despite the name)
* once sandbox keys land — un-skipped per issue #106.
*
* Per Phase 0b (issue #102).
*/

import { assertEquals } from 'https://deno.land/std@0.168.0/testing/asserts.ts';

Deno.test('module loads without throwing', async () => {
Deno.env.set('STRIPE_SECRET_KEY', '');
Deno.env.set('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co');
Deno.env.set('SUPABASE_SERVICE_ROLE_KEY', 'eyJfake');
Deno.env.set('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'eyJfake');
Deno.env.set('NEXT_PUBLIC_SITE_URL', 'http://localhost:3000');

try {
await import('./index.ts');
} catch (err) {
if (
err instanceof Error &&
!err.message.includes('serve') &&
!err.message.includes('bind')
) {
throw err;
}
}
});

Deno.test(
'request body validation — empty price_id returns 400 (documented contract)',
() => {
const valid = { price_id: 'price_abc', customer_email: 'a@b.co' };
const emptyPrice = { price_id: '', customer_email: 'a@b.co' };
const missingPrice = { customer_email: 'a@b.co' };

assertEquals(typeof valid.price_id, 'string');
assertEquals(emptyPrice.price_id.trim(), '');
assertEquals('price_id' in missingPrice, false);
}
);

Deno.test(
'email validation regex matches the same shape as payment-service.ts',
() => {
// Same regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const valid = 'a@b.co';
const noAt = 'no-at-sign';
const noDomain = 'a@b';
const trailingSpace = 'a@b.co ';

const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
assertEquals(re.test(valid), true);
assertEquals(re.test(noAt), false);
assertEquals(re.test(noDomain), false);
// Trailing space fails (the function .trim()s first, then validates)
assertEquals(re.test(trailingSpace), false);
assertEquals(re.test(trailingSpace.trim()), true);
}
);

Deno.test(
'subscription_data.metadata carries template_user_id and customer_email',
() => {
// Contract: the Stripe API call sets subscription_data.metadata so the
// webhook can read those fields when the subscription record is
// created. Without this the NOT NULL constraint on
// subscriptions.template_user_id would fail. See companion fix in
// stripe-webhook/index.ts.
const userId = 'auth-uuid';
const email = 'customer@example.com';
const expectedMetadata = {
template_user_id: userId,
customer_email: email,
};
assertEquals(expectedMetadata.template_user_id, userId);
assertEquals(expectedMetadata.customer_email, email);
}
);

Deno.test('no subscriptions row is inserted at session-creation time', () => {
// Contract: this function ONLY creates a Stripe Checkout Session and
// returns its id. The actual subscriptions row is inserted by
// stripe-webhook when `customer.subscription.created` fires later
// (after the customer completes checkout in the Stripe-hosted page).
// Why: at session-creation we don't have a provider_subscription_id
// (Stripe assigns it post-checkout), and the table's
// provider_subscription_id is NOT NULL UNIQUE.
//
// This test documents that contract — no actual SDK is called here.
const flow = [
'create-session',
'redirect-to-stripe',
'customer-pays',
'webhook-inserts-row',
];
assertEquals(
flow.indexOf('webhook-inserts-row') > flow.indexOf('create-session'),
true
);
});
133 changes: 133 additions & 0 deletions supabase/functions/create-stripe-subscription/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* create-stripe-subscription Edge Function
*
* Creates a Stripe Checkout Session in subscription mode for a recurring
* payment. The browser redirects to the session's URL; once the customer
* completes the flow, Stripe fires `customer.subscription.created` and the
* existing `stripe-webhook` handler upserts the `subscriptions` row.
*
* Phase 0b of the payment Edge Functions epic (issue #100, sub-issue #102).
*
* REQUEST
* POST /functions/v1/create-stripe-subscription
* Authorization: Bearer <user JWT>
* Body: { price_id: string, customer_email: string }
*
* RESPONSE
* 200 OK: { sessionId: string }
* 400 Bad Request: { error: string } (validation)
* 401 Unauthorized: { error: string }
* 500 Internal Server Error: { error: string }
*
* SECURITY MODEL
* - JWT verified via NEXT_PUBLIC_SUPABASE_ANON_KEY
* - The caller's user_id flows into Stripe's subscription metadata
* (template_user_id) so the webhook can attribute the new
* subscriptions row to the right user. Without this, the NOT NULL
* constraint on subscriptions.template_user_id would fail on insert.
* - We DON'T insert a subscriptions row here — the row is created by
* stripe-webhook when `customer.subscription.created` fires. That
* event includes the real provider_subscription_id (which we don't
* have yet) and the real plan_amount / plan_interval / status.
* - The `price_id` is operator-configured in Stripe Dashboard; the
* plan amount + interval come from there. We pass it through.
*
* NOTES
* - No payment_intents row is involved. Subscriptions are a separate
* code path that bypasses the one-off payment_intents/results
* lifecycle. The browser caller (SubscriptionManager) passes the
* price_id directly.
* - Email validation matches the pattern in payment-service.ts.
*/

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import Stripe from 'https://esm.sh/stripe@14.21.0?target=deno';
import { handleCors, jsonResponse } from '../_shared/cors.ts';
import { getAuthenticatedUserId, UnauthorizedError } from '../_shared/auth.ts';

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') || '', {
apiVersion: '2024-06-20',
httpClient: Stripe.createFetchHttpClient(),
});

const siteUrl = Deno.env.get('NEXT_PUBLIC_SITE_URL') || 'http://localhost:3000';

interface RequestBody {
price_id?: string;
customer_email?: string;
}

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

serve(async (req) => {
const cors = handleCors(req);
if (cors) return cors;

if (req.method !== 'POST') {
return jsonResponse(req, { error: 'Method not allowed' }, 405);
}

try {
const userId = await getAuthenticatedUserId(req);

let body: RequestBody;
try {
body = await req.json();
} catch {
return jsonResponse(req, { error: 'Invalid JSON body' }, 400);
}

const priceId = body.price_id?.trim();
if (!priceId) {
return jsonResponse(req, { error: 'price_id is required' }, 400);
}

const customerEmail = body.customer_email?.trim().toLowerCase();
if (!customerEmail || !EMAIL_REGEX.test(customerEmail)) {
return jsonResponse(
req,
{ error: 'customer_email is required and must be a valid email' },
400
);
}

// Create the Stripe Checkout Session in subscription mode.
// subscription_data.metadata propagates to the Subscription object
// that Stripe creates after checkout completes, so the
// `customer.subscription.created` webhook can read template_user_id
// and customer_email to satisfy the NOT NULL constraints on the
// `subscriptions` table.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
customer_email: customerEmail,
subscription_data: {
metadata: {
template_user_id: userId,
customer_email: customerEmail,
},
},
success_url: `${siteUrl}/payment-result?session_id={CHECKOUT_SESSION_ID}&status=subscribed`,
cancel_url: `${siteUrl}/payment-result?status=cancelled`,
// No client_reference_id — there's no pre-existing intent to link to.
});

return jsonResponse(req, { sessionId: session.id });
} catch (err) {
if (err instanceof UnauthorizedError) {
return jsonResponse(req, { error: err.message }, 401);
}
console.error('create-stripe-subscription error:', err);
return jsonResponse(
req,
{ error: err instanceof Error ? err.message : 'Internal server error' },
500
);
}
});
22 changes: 21 additions & 1 deletion supabase/functions/stripe-webhook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,30 @@ async function handleSubscriptionEvent(
) {
const subscription = event.data.object as Stripe.Subscription;

// template_user_id and customer_email come from the metadata that
// create-stripe-subscription sets via subscription_data.metadata when
// creating the Checkout Session. Without these the NOT NULL
// constraints on subscriptions.template_user_id / customer_email fail.
// (Phase 0b — issue #102 — paired this webhook fix with the new
// create-stripe-subscription function.)
const templateUserId = subscription.metadata?.template_user_id;
const customerEmail = subscription.metadata?.customer_email;

if (!templateUserId) {
console.error(
`customer.subscription event missing template_user_id metadata; ` +
`subscription_id=${subscription.id}. Ensure the Checkout Session was ` +
`created via create-stripe-subscription which sets ` +
`subscription_data.metadata.template_user_id.`
);
return { handled: false };
}

const subscriptionData = {
provider: 'stripe',
provider_subscription_id: subscription.id,
customer_email: subscription.metadata?.customer_email || '',
template_user_id: templateUserId,
customer_email: customerEmail || '',
plan_amount: subscription.items.data[0]?.price.unit_amount || 0,
plan_interval:
subscription.items.data[0]?.price.recurring?.interval || 'month',
Expand Down
Loading