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' },
+ });
+}