diff --git a/apps/minting-service/handlers/stripe-webhook.ts b/apps/minting-service/handlers/stripe-webhook.ts index 50e7b588..6b5b58b7 100644 --- a/apps/minting-service/handlers/stripe-webhook.ts +++ b/apps/minting-service/handlers/stripe-webhook.ts @@ -12,7 +12,7 @@ import { import { loadEnv } from '../src/lib/env.js'; import { getStripe } from '../src/lib/stripe.js'; import { mintToken } from '../src/lib/sign.js'; -import { sendLicenseEmail } from '../src/lib/email.js'; +import { sendLicenseEmail, sendRevocationEmail } from '../src/lib/email.js'; import { handleEvent, type HandlerDeps } from '../src/lib/handlers.js'; export const config = { api: { bodyParser: false } }; @@ -61,6 +61,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse): revokeLicense, mintToken, sendLicenseEmail, + sendRevocationEmail, privateKeyHex: env.LICENSE_SIGNING_PRIVATE_KEY_HEX, resendApiKey: env.RESEND_API_KEY, emailFrom: env.EMAIL_FROM, diff --git a/apps/minting-service/src/lib/email.ts b/apps/minting-service/src/lib/email.ts index 441d8dce..7558c0d3 100644 --- a/apps/minting-service/src/lib/email.ts +++ b/apps/minting-service/src/lib/email.ts @@ -101,3 +101,54 @@ export async function sendLicenseEmail(args: { } return { resendId: result.data.id }; } + +export interface RevocationEmailVars { + tier: MintableTier; +} + +export function renderRevocationEmail(vars: RevocationEmailVars): RenderedEmail { + const subject = `Your ThreadPlane license has been revoked`; + + const text = `Your ThreadPlane ${vars.tier} license has been revoked because the +underlying payment was refunded. + +The token previously delivered will fail signature checks at boot and +@ngaf/chat will fall back to a noncommercial-use warning. + +If you believe this is in error, reply to this email. + +-- The ThreadPlane team +`; + + const html = `

Your ThreadPlane ${escapeHtml(vars.tier)} license has been revoked because the underlying payment was refunded.

+

The token previously delivered will fail signature checks at boot and @ngaf/chat will fall back to a noncommercial-use warning.

+

If you believe this is in error, reply to this email.

+

-- The ThreadPlane team

+`; + + return { subject, text, html }; +} + +export async function sendRevocationEmail(args: { + resendApiKey: string; + from: string; + to: string; + vars: RevocationEmailVars; +}): Promise<{ resendId: string }> { + const resend = new Resend(args.resendApiKey); + const rendered = renderRevocationEmail(args.vars); + const result = await resend.emails.send({ + from: args.from, + to: args.to, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }); + if (result.error) { + throw new Error(`Resend send failed: ${result.error.message}`); + } + if (!result.data?.id) { + throw new Error('Resend send returned no id'); + } + return { resendId: result.data.id }; +} diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index ea57f8f3..357a7d0c 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect, vi } from 'vitest'; import type Stripe from 'stripe'; -import { handleEvent, handleCheckoutCompleted, type HandlerDeps } from './handlers.js'; +import { handleEvent, handleCheckoutCompleted, handleChargeRefunded, type HandlerDeps } from './handlers.js'; function makeDeps(overrides: Partial = {}): HandlerDeps { return { @@ -14,6 +14,7 @@ function makeDeps(overrides: Partial = {}): HandlerDeps { revokeLicense: vi.fn(), mintToken: vi.fn().mockResolvedValue('mock.token'), sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_mock' }), + sendRevocationEmail: vi.fn().mockResolvedValue({ resendId: 're_revoke' }), privateKeyHex: 'a'.repeat(64), resendApiKey: 're_test', emailFrom: 'noreply@example.com', @@ -171,6 +172,25 @@ describe('handleCheckoutCompleted', () => { await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/customer_creation/); }); + it('dispatches charge.refunded to handleChargeRefunded', async () => { + const charge = { id: 'ch_x', payment_intent: 'pi_test_123' } as Stripe.Charge; + const deps = makeDeps({ + getLicense: vi.fn().mockResolvedValue({ + customerEmail: 'buyer@example.com', + tier: 'indie', + }), + revokeLicense: vi.fn().mockResolvedValue({}), + }); + await handleEvent(evt('charge.refunded', charge), deps); + expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123'); + expect(deps.sendRevocationEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'buyer@example.com', + vars: { tier: 'indie' }, + }), + ); + }); + it('throws when the session has no customer email', async () => { const session = paymentSession({ customer_details: { email: null } as Stripe.Checkout.Session.CustomerDetails, @@ -184,3 +204,41 @@ describe('handleCheckoutCompleted', () => { await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/); }); }); + +describe('handleChargeRefunded', () => { + const charge = (overrides: Partial = {}): Stripe.Charge => + ({ id: 'ch_test', payment_intent: 'pi_test_123', ...overrides }) as Stripe.Charge; + + it('revokes and emails when a license exists for the charge', async () => { + const deps = makeDeps({ + getLicense: vi.fn().mockResolvedValue({ + customerEmail: 'buyer@example.com', + tier: 'developer_seat', + }), + revokeLicense: vi.fn().mockResolvedValue({}), + }); + await handleChargeRefunded(charge(), deps); + expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123'); + expect(deps.sendRevocationEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'buyer@example.com', + vars: { tier: 'developer_seat' }, + }), + ); + }); + + it('no-ops when no license exists for the payment_intent', async () => { + const deps = makeDeps({ + getLicense: vi.fn().mockResolvedValue(null), + }); + await handleChargeRefunded(charge(), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + expect(deps.sendRevocationEmail).not.toHaveBeenCalled(); + }); + + it('no-ops when charge has no payment_intent', async () => { + const deps = makeDeps(); + await handleChargeRefunded(charge({ payment_intent: null }), deps); + expect(deps.getLicense).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index 39a12dc0..a85109ea 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -6,7 +6,7 @@ import type { UpsertLicenseInput, } from '@ngaf/db'; import type { MintInput } from './sign.js'; -import type { LicenseEmailVars } from './email.js'; +import type { LicenseEmailVars, RevocationEmailVars } from './email.js'; import { extractTier, computeSeats } from './tier.js'; /** @@ -27,6 +27,12 @@ export interface HandlerDeps { to: string; vars: LicenseEmailVars; }) => Promise<{ resendId: string }>; + sendRevocationEmail: (args: { + resendApiKey: string; + from: string; + to: string; + vars: RevocationEmailVars; + }) => Promise<{ resendId: string }>; privateKeyHex: string; resendApiKey: string; emailFrom: string; @@ -42,6 +48,9 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); break; + case 'charge.refunded': + await handleChargeRefunded(event.data.object as Stripe.Charge, deps); + break; default: return; } @@ -121,3 +130,44 @@ export async function handleCheckoutCompleted( vars: { tier, seats, token, expiresAt }, }); } + +/** + * Handles a Stripe charge.refunded event by revoking the matching license + * and notifying the customer. Both partial and full refunds revoke; the + * heuristic is that any refund signals the customer wants out, and they + * can re-purchase if needed. + * + * Idempotent: re-runs on a refunded charge whose license is already + * revoked simply re-send the email; the DB row stays revoked. + */ +export async function handleChargeRefunded( + charge: Stripe.Charge, + deps: HandlerDeps, +): Promise { + const paymentId = typeof charge.payment_intent === 'string' + ? charge.payment_intent + : charge.payment_intent?.id; + if (!paymentId) { + console.log(`handleChargeRefunded: charge ${charge.id} has no payment_intent`); + return; + } + + const existing = await deps.getLicense(deps.db, paymentId); + if (!existing) { + console.log(`handleChargeRefunded: no license for payment_intent ${paymentId}`); + return; + } + + const revoked = await deps.revokeLicense(deps.db, paymentId); + if (!revoked) { + console.log(`handleChargeRefunded: revokeLicense returned null for ${paymentId}`); + return; + } + + await deps.sendRevocationEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: existing.customerEmail, + vars: { tier: existing.tier as 'indie' | 'developer_seat' | 'app_deployment' }, + }); +}