Skip to content

Commit b8cd779

Browse files
lunarthegreyclaude
andcommitted
feat(billing): purchasable gift/share membership codes (reveal-once)
A signed-in member can buy N membership codes to give to family/friends. Distinct from the self-upgrade flow: the purchase mints shareable codes bound to the buyer instead of extending their own membership. Recipients redeem via the unchanged account-scoped redeem flow. Data model: - billingOrders: + kind ('self'|'gift'), quantity, and a TRANSIENT giftReveal buffer (+ giftRevealAck). - redemptionCodes: + purchasedByUserId (+ by_purchaser index) + purchasedByOrderId; mintedByAdminId is now optional (a purchase has no admin). Flow (reveal-once for an async, webhook-granted purchase): - createCheckout takes {kind, quantity}; gift amount = quantity × duration price. - ingestEvent pre-generates the codes (CSPRNG in the action); applyEvent's gift branch inserts them HASH-ONLY (bound to buyer+order) and stashes the plaintext in the order's giftReveal buffer instead of calling applyMembership. - the return poll (getOrderStatus) returns the codes ONCE; /account/gift-codes/ack clears the buffer; a billing-gift-reveal cron sweeps any unacked buffer after 24h. Durable code storage is always hash-only — plaintext never persists. - single-mint idempotency rides the existing webhook dedupe + the serializable order.status==='paid' guard. Member surface: - GET /api/v1/account/codes → masked "codes you've bought" list (prefix + tier + status + redeemed-at; never the hash or the recipient). - new GiftCodes panel (buy + bought-list) and GiftRevealModal (blocking reveal, AccountNumberReveal pattern) on /account; the order-return handler branches on kind so a gift purchase shows the reveal, not a "membership active" toast. Tests: gift checkout pricing; finished IPN mints buyer-bound hash-only codes without extending the buyer; reveal-once + ack clears; replay doesn't double-mint; purchased-list reflects redemption. Gates green: typecheck 0, 367 tests + 1 todo, lint 0, build ok. New gift.* strings are English-only for now (other locales fall back) — the Paraglide migration will machine-translate them. Deploy convex + SPA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd5e884 commit b8cd779

15 files changed

Lines changed: 944 additions & 19 deletions

File tree

convex/billing.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,194 @@ describe('billing.getOrderStatus scoping', () => {
596596
expect(theirs).toBeNull();
597597
});
598598
});
599+
600+
describe('billing gift codes', () => {
601+
beforeEach(() => {
602+
vi.stubEnv('NOWPAYMENTS_API_KEY', 'np-key');
603+
vi.stubEnv('NOWPAYMENTS_API_URL', 'https://api.nowpayments.example');
604+
vi.stubEnv('PUBLIC_BASE_URL', 'https://beta.freesocks.example');
605+
vi.stubEnv('NOWPAYMENTS_IPN_SECRET', IPN_SECRET);
606+
});
607+
afterEach(() => {
608+
vi.unstubAllEnvs();
609+
vi.unstubAllGlobals();
610+
});
611+
612+
const insertGiftOrder = (
613+
t: ReturnType<typeof convexTest>,
614+
userId: Id<'users'>,
615+
tierId: Id<'tiers'>,
616+
opaqueRef: string,
617+
quantity: number,
618+
): Promise<Id<'billingOrders'>> =>
619+
t.run((ctx) =>
620+
ctx.db.insert('billingOrders', {
621+
processor: 'nowpayments',
622+
opaqueRef,
623+
userId,
624+
tierId,
625+
durationDays: 91,
626+
amountCents: 1400 * quantity,
627+
currency: 'USD',
628+
status: 'pending',
629+
kind: 'gift',
630+
quantity,
631+
updatedAt: Date.now(),
632+
}),
633+
);
634+
635+
test('a gift checkout inserts a gift order priced by quantity', async () => {
636+
const t = convexTest(schema, modules);
637+
const { userId, memberTierId } = await seedTiersAndUser(t);
638+
await enableBilling(t);
639+
vi.stubGlobal(
640+
'fetch',
641+
vi.fn(
642+
async () =>
643+
new Response(
644+
JSON.stringify({ id: 'inv_g', invoice_url: 'https://pay.example/i/inv_g' }),
645+
{ status: 200, headers: { 'content-type': 'application/json' } },
646+
),
647+
),
648+
);
649+
const res = await t.action(internal.billing.createCheckout, {
650+
userId,
651+
processor: 'nowpayments',
652+
months: 3,
653+
kind: 'gift',
654+
quantity: 2,
655+
});
656+
await t.run(async (ctx) => {
657+
const order = await ctx.db
658+
.query('billingOrders')
659+
.withIndex('by_opaque_ref', (q) => q.eq('opaqueRef', res.orderRef))
660+
.unique();
661+
expect(order?.kind).toBe('gift');
662+
expect(order?.quantity).toBe(2);
663+
expect(order?.amountCents).toBe(2800);
664+
expect(order?.tierId).toBe(memberTierId);
665+
});
666+
});
667+
668+
test('a finished IPN mints codes for the buyer WITHOUT extending their own membership', async () => {
669+
const t = convexTest(schema, modules);
670+
const { userId, freeTierId, memberTierId } = await seedTiersAndUser(t);
671+
await insertGiftOrder(t, userId, memberTierId, 'ref-gift', 2);
672+
const payload = { payment_status: 'finished', payment_id: 100, order_id: 'ref-gift' };
673+
const res = await t.action(internal.billing.ingestEvent, {
674+
processor: 'nowpayments',
675+
rawBody: JSON.stringify(payload),
676+
signature: await signIpn(payload),
677+
});
678+
expect(res).toEqual({ ok: true, applied: true });
679+
await t.run(async (ctx) => {
680+
const order = await ctx.db
681+
.query('billingOrders')
682+
.withIndex('by_opaque_ref', (q) => q.eq('opaqueRef', 'ref-gift'))
683+
.unique();
684+
expect(order?.status).toBe('paid');
685+
expect(order?.giftReveal?.length).toBe(2);
686+
const codes = await ctx.db
687+
.query('redemptionCodes')
688+
.withIndex('by_purchaser', (q) => q.eq('purchasedByUserId', userId))
689+
.collect();
690+
expect(codes).toHaveLength(2);
691+
expect(
692+
codes.every(
693+
(c) =>
694+
c.status === 'active' &&
695+
c.purchasedByOrderId === order!._id &&
696+
c.mintedByAdminId === undefined,
697+
),
698+
).toBe(true);
699+
const user = await ctx.db.get(userId);
700+
expect(user?.tierId).toBe(freeTierId); // membership NOT extended
701+
expect(user?.membershipExpiresAt).toBeUndefined();
702+
});
703+
});
704+
705+
test('getOrderStatus reveals the codes once; ack clears the buffer', async () => {
706+
const t = convexTest(schema, modules);
707+
const { userId, memberTierId } = await seedTiersAndUser(t);
708+
await insertGiftOrder(t, userId, memberTierId, 'ref-gift2', 1);
709+
const payload = { payment_status: 'finished', payment_id: 101, order_id: 'ref-gift2' };
710+
await t.action(internal.billing.ingestEvent, {
711+
processor: 'nowpayments',
712+
rawBody: JSON.stringify(payload),
713+
signature: await signIpn(payload),
714+
});
715+
const status1 = await t.query(internal.billing.getOrderStatus, {
716+
opaqueRef: 'ref-gift2',
717+
userId,
718+
});
719+
expect(status1?.kind).toBe('gift');
720+
expect(status1?.giftCodes).toHaveLength(1);
721+
await t.mutation(internal.billing.ackGiftReveal, { opaqueRef: 'ref-gift2', userId });
722+
const status2 = await t.query(internal.billing.getOrderStatus, {
723+
opaqueRef: 'ref-gift2',
724+
userId,
725+
});
726+
expect(status2?.giftCodes).toHaveLength(0);
727+
});
728+
729+
test('a replayed gift IPN does not mint extra codes', async () => {
730+
const t = convexTest(schema, modules);
731+
const { userId, memberTierId } = await seedTiersAndUser(t);
732+
await insertGiftOrder(t, userId, memberTierId, 'ref-gift3', 3);
733+
const payload = { payment_status: 'finished', payment_id: 102, order_id: 'ref-gift3' };
734+
const sig = await signIpn(payload);
735+
await t.action(internal.billing.ingestEvent, {
736+
processor: 'nowpayments',
737+
rawBody: JSON.stringify(payload),
738+
signature: sig,
739+
});
740+
await t.action(internal.billing.ingestEvent, {
741+
processor: 'nowpayments',
742+
rawBody: JSON.stringify(payload),
743+
signature: sig,
744+
});
745+
const count = await t.run(
746+
async (ctx) =>
747+
(
748+
await ctx.db
749+
.query('redemptionCodes')
750+
.withIndex('by_purchaser', (q) => q.eq('purchasedByUserId', userId))
751+
.collect()
752+
).length,
753+
);
754+
expect(count).toBe(3);
755+
});
756+
757+
test('listPurchasedCodes returns the buyer’s codes, masked, and reflects redemption', async () => {
758+
const t = convexTest(schema, modules);
759+
const { userId, memberTierId } = await seedTiersAndUser(t);
760+
await insertGiftOrder(t, userId, memberTierId, 'ref-gift4', 1);
761+
const payload = { payment_status: 'finished', payment_id: 103, order_id: 'ref-gift4' };
762+
await t.action(internal.billing.ingestEvent, {
763+
processor: 'nowpayments',
764+
rawBody: JSON.stringify(payload),
765+
signature: await signIpn(payload),
766+
});
767+
let list = await t.query(internal.membershipCodes.listPurchasedCodes, { userId });
768+
expect(list).toHaveLength(1);
769+
expect(list[0].status).toBe('active');
770+
expect(list[0].codePrefix).toMatch(/^FSM-/);
771+
772+
// The recipient (a different account) redeems the gift code.
773+
const recipientId = await t.run((ctx) =>
774+
ctx.db.insert('users', { tierId: memberTierId, status: 'active', updatedAt: Date.now() }),
775+
);
776+
const codeHash = await t.run(
777+
async (ctx) =>
778+
(await ctx.db
779+
.query('redemptionCodes')
780+
.withIndex('by_purchaser', (q) => q.eq('purchasedByUserId', userId))
781+
.first())!.codeHash,
782+
);
783+
await t.mutation(internal.membershipCodes.consumeAndGrant, { userId: recipientId, codeHash });
784+
785+
list = await t.query(internal.membershipCodes.listPurchasedCodes, { userId });
786+
expect(list[0].status).toBe('redeemed');
787+
expect(list[0].redeemedAt).not.toBeNull();
788+
});
789+
});

0 commit comments

Comments
 (0)