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
3 changes: 2 additions & 1 deletion apps/minting-service/handlers/stripe-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions apps/minting-service/src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<p>Your ThreadPlane <strong>${escapeHtml(vars.tier)}</strong> license has been revoked because the underlying payment was refunded.</p>
<p>The token previously delivered will fail signature checks at boot and <code>@ngaf/chat</code> will fall back to a noncommercial-use warning.</p>
<p>If you believe this is in error, reply to this email.</p>
<p>-- The ThreadPlane team</p>
`;

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 };
}
60 changes: 59 additions & 1 deletion apps/minting-service/src/lib/handlers.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): HandlerDeps {
return {
Expand All @@ -14,6 +14,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): 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',
Expand Down Expand Up @@ -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,
Expand All @@ -184,3 +204,41 @@ describe('handleCheckoutCompleted', () => {
await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/);
});
});

describe('handleChargeRefunded', () => {
const charge = (overrides: Partial<Stripe.Charge> = {}): 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();
});
});
52 changes: 51 additions & 1 deletion apps/minting-service/src/lib/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<void> {
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' },
});
}