diff --git a/apps/web/__tests__/unit/signed-baa-webhook.test.ts b/apps/web/__tests__/unit/signed-baa-webhook.test.ts index 1ffbcee885..d53f57f1cc 100644 --- a/apps/web/__tests__/unit/signed-baa-webhook.test.ts +++ b/apps/web/__tests__/unit/signed-baa-webhook.test.ts @@ -17,7 +17,7 @@ const mockDbChain = { function resetDbChain() { for (const key of Object.keys(mockDbChain)) { const fn = mockDbChain[key as keyof typeof mockDbChain]; - fn.mockClear(); + fn.mockReset(); } mockDbChain.select.mockReturnValue(mockDbChain); mockDbChain.from.mockReturnValue(mockDbChain); @@ -108,6 +108,7 @@ vi.mock("@cap/utils", () => ({ vi.mock("drizzle-orm", () => ({ and: vi.fn((...args: unknown[]) => args), eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + inArray: vi.fn((a: unknown, b: unknown[]) => ({ inArray: [a, b] })), isNull: vi.fn((a: unknown) => ({ isNull: a })), ne: vi.fn((a: unknown, b: unknown) => ({ ne: [a, b] })), sql: (parts: TemplateStringsArray, ...values: unknown[]) => ({ @@ -575,6 +576,34 @@ describe("Signed BAA Payment Link webhooks", () => { status: "paid", stripeSubscriptionId: "sub_link_baa", }; + const waivedSubscription = { + ...subscription, + metadata: { + proRequirement: "waived", + baaRecordId: paid.id, + organizationId: paid.organizationId, + userId: paid.userId, + }, + }; + const legacyOwner = { + ...owner, + stripeSubscriptionId: "12345", + stripeSubscriptionStatus: "active", + inviteQuota: 6, + }; + const linkedRecord = { + id: paid.id, + organizationId: paid.organizationId, + userId: paid.userId, + subscriptionId: subscription.id, + }; + + function mockWaivedSubscription(value = waivedSubscription) { + mockStripe.subscriptions.retrieve.mockImplementation(async (id: string) => { + if (id === value.id) return value; + throw new Error(`No such subscription: ${id}`); + }); + } beforeEach(async () => { vi.clearAllMocks(); @@ -616,6 +645,73 @@ describe("Signed BAA Payment Link webhooks", () => { expect(sendEmail).not.toHaveBeenCalled(); }); + it("reconciles a waived paid checkout without retrieving or changing legacy Pro", async () => { + mockWaivedSubscription(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: session }, + }); + mockDbChain.limit + .mockResolvedValueOnce([legacyOwner]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([pending]) + .mockResolvedValueOnce([{ ownerId: owner.id }]) + .mockResolvedValueOnce([{ ...paid, status: "pending" }]) + .mockResolvedValueOnce([paid]); + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockDbChain.set).toHaveBeenCalledWith({ status: "paid" }); + expect(mockStripe.subscriptions.retrieve).not.toHaveBeenCalledWith("12345"); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(mockDbChain.update).not.toHaveBeenCalledWith(users); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it.each(["paid", "active"])( + "preserves a %s waived BAA through subscription lifecycle updates", + async (status) => { + mockWaivedSubscription(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { object: waivedSubscription }, + }); + mockDbChain.limit + .mockResolvedValueOnce([ + { + ...paid, + status, + signedAt: status === "active" ? new Date() : null, + }, + ]) + .mockResolvedValueOnce([legacyOwner]); + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockStripe.subscriptions.retrieve).not.toHaveBeenCalledWith( + "12345", + ); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(mockDbChain.set).not.toHaveBeenCalledWith( + expect.objectContaining({ status: "canceled" }), + ); + expect(mockDbChain.update).not.toHaveBeenCalledWith(users); + expect(sendEmail).not.toHaveBeenCalled(); + }, + ); + + it.each(["canceled", "unpaid"])( + "honors a waived BAA's current %s state over a stale active event", + async (status) => { + mockWaivedSubscription({ ...waivedSubscription, status }); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { object: waivedSubscription }, + }); + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockDbChain.set).toHaveBeenCalledWith( + expect.objectContaining({ status: "canceled" }), + ); + expect(mockDbChain.update).not.toHaveBeenCalledWith(users); + }, + ); + it("acknowledges an unpaid BAA checkout without granting payment or Pro", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "checkout.session.completed", @@ -760,7 +856,7 @@ describe("Signed BAA Payment Link webhooks", () => { mockStripe.subscriptions.list.mockResolvedValue({ data: [pro] }); mockDbChain.limit .mockResolvedValueOnce([owner]) - .mockResolvedValueOnce([{ subscriptionId: "sub_link_baa" }]); + .mockResolvedValueOnce([linkedRecord]); expect((await POST(makeWebhookRequest())).status).toBe(200); expect(mockStripe.subscriptions.cancel).toHaveBeenCalledExactlyOnceWith( "sub_link_baa", @@ -768,6 +864,108 @@ describe("Signed BAA Payment Link webhooks", () => { expect(mockDbChain.set).toHaveBeenCalledWith({ status: "canceled" }); }); + it.each(["customer.subscription.updated", "customer.subscription.deleted"])( + "preserves a bound waived BAA during %s Pro cleanup", + async (eventType) => { + const pro = { + ...proSubscription, + status: eventType.endsWith("deleted") ? "canceled" : "unpaid", + }; + mockWaivedSubscription(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: eventType, + data: { object: pro }, + }); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_pro", + email: owner.email, + metadata: { userId: owner.id }, + }); + mockStripe.subscriptions.list.mockResolvedValue({ data: [pro] }); + if (eventType.endsWith("deleted")) { + mockDbChain.limit.mockResolvedValueOnce([linkedRecord]); + mockDbChain.where + .mockReturnValueOnce(mockDbChain) + .mockResolvedValueOnce([owner]); + } else { + mockDbChain.limit + .mockResolvedValueOnce([owner]) + .mockResolvedValueOnce([linkedRecord]); + } + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(mockDbChain.update).not.toHaveBeenCalledWith(signedBaas); + }, + ); + + it.each(["mismatched record", "missing record"])( + "does not waive Pro cleanup for a BAA with a %s", + async (binding) => { + const pro = { ...proSubscription, status: "unpaid" }; + mockWaivedSubscription(); + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { object: pro }, + }); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_pro", + email: owner.email, + metadata: { userId: owner.id }, + }); + mockStripe.subscriptions.list.mockResolvedValue({ + data: [pro, waivedSubscription], + }); + mockDbChain.limit + .mockResolvedValueOnce([owner]) + .mockResolvedValueOnce( + binding === "missing record" + ? [] + : [{ ...linkedRecord, id: "different-baa-record" }], + ); + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockStripe.subscriptions.cancel).toHaveBeenCalledExactlyOnceWith( + subscription.id, + ); + expect(mockDbChain.set).toHaveBeenCalledWith({ status: "canceled" }); + }, + ); + + it("resolves a bound waiver on an alternate customer while canceling ordinary BAAs", async () => { + const pro = { ...proSubscription, status: "unpaid", customer: "cus_link" }; + const ordinaryBaa = { ...subscription, id: "sub_ordinary_baa" }; + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { object: pro }, + }); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_link", + email: owner.email, + metadata: { userId: owner.id }, + }); + mockStripe.subscriptions.list.mockResolvedValue({ + data: [pro, waivedSubscription, ordinaryBaa], + }); + mockDbChain.limit + .mockResolvedValueOnce([owner]) + .mockResolvedValueOnce([linkedRecord]); + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockDbChain.where).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.arrayContaining([ + { + inArray: [signedBaas.stripeSubscriptionId, [subscription.id]], + }, + ]), + ]), + ); + expect(mockStripe.subscriptions.cancel).toHaveBeenCalledExactlyOnceWith( + ordinaryBaa.id, + ); + expect(mockDbChain.where).not.toHaveBeenCalledWith({ + eq: [signedBaas.stripeSubscriptionId, waivedSubscription.id], + }); + }); + it("preserves ordinary Pro checkout and its seat quota", async () => { const pro = { id: "sub_pro", diff --git a/apps/web/__tests__/unit/signed-baa.test.ts b/apps/web/__tests__/unit/signed-baa.test.ts index 8bc0ed6d1d..2a0320ef10 100644 --- a/apps/web/__tests__/unit/signed-baa.test.ts +++ b/apps/web/__tests__/unit/signed-baa.test.ts @@ -1,10 +1,10 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sendEmail } from "@cap/database/emails/config"; -import { signedBaas } from "@cap/database/schema"; +import { signedBaas, users } from "@cap/database/schema"; import { STRIPE_SIGNED_BAA_PRICE_IDS } from "@cap/utils"; import type Stripe from "stripe"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { attachPaidBaaCheckout } from "@/lib/baa/billing"; +import { attachPaidBaaCheckout, hasBaaProWaiver } from "@/lib/baa/billing"; import { generateSignedBaaPdf } from "@/lib/baa/generate-signed-baa-pdf"; const mockDb = { @@ -106,7 +106,7 @@ const mockGetCurrentUser = getCurrentUser as ReturnType; function resetMockDb() { for (const key of Object.keys(mockDb)) { const fn = mockDb[key as keyof typeof mockDb]; - fn.mockClear(); + fn.mockReset(); } mockDb.select.mockReturnValue(mockDb); mockDb.insert.mockReturnValue(mockDb); @@ -251,6 +251,28 @@ describe("Signed BAA entitlement", () => { ).rejects.toThrow("active Cap Pro subscription"); expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); + + it("does not treat a legacy Pro placeholder as permission for a new BAA purchase", async () => { + mockOwner({ + stripeCustomerId: "cus_pro", + stripeSubscriptionId: "12345", + stripeSubscriptionStatus: "active", + inviteQuota: 6, + }); + mockStripe.subscriptions.retrieve.mockRejectedValue( + new Error("No such subscription: 12345"), + ); + const { purchaseSignedBaa } = await import( + "@/actions/organization/signed-baa" + ); + await expect( + purchaseSignedBaa("org-1" as never, VALID_INPUT), + ).rejects.toThrow("active Cap Pro subscription"); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); }); const VALID_INPUT = { @@ -277,7 +299,10 @@ async function setupPurchaseAttempt() { }); mockDb.limit.mockResolvedValueOnce([]); vi.mocked(generateSignedBaaPdf).mockResolvedValue(new Uint8Array([1, 2, 3])); - vi.mocked(sendEmail).mockResolvedValue(undefined as never); + vi.mocked(sendEmail).mockResolvedValue({ + data: { id: "email-1" }, + error: null, + }); mockStripe.subscriptions.retrieve.mockResolvedValue({ id: "sub_active", customer: "cus_1", @@ -376,7 +401,10 @@ describe("Signed BAA Stripe recovery", () => { .mockReturnValueOnce(mockDb) .mockReturnValueOnce({ affectedRows: 1 }); vi.mocked(generateSignedBaaPdf).mockResolvedValue(new Uint8Array([1])); - vi.mocked(sendEmail).mockResolvedValue(undefined as never); + vi.mocked(sendEmail).mockResolvedValue({ + data: { id: "email-1" }, + error: null, + }); mockStripe.subscriptions.retrieve.mockResolvedValue({ id: "sub_active", customer: "cus_1", @@ -504,12 +532,12 @@ describe("Signed BAA Stripe recovery", () => { const paidRecord = { id: "baa-record-1", - organizationId: "org-1", - userId: "owner-1", + organizationId: "org-1" as never, + userId: "owner-1" as never, status: "paid", stripeSubscriptionId: "sub_baa_paid" as string | null, - signedAt: null, - emailSentAt: null, + signedAt: null as Date | null, + emailSentAt: null as Date | null, updatedAt: new Date(), ...VALID_INPUT, signatureData: VALID_INPUT.signatureDataUrl, @@ -524,6 +552,16 @@ const paidSubscription = { latest_invoice: { status: "paid" }, } as unknown as Stripe.Subscription; +const waivedSubscription: Stripe.Subscription = { + ...paidSubscription, + metadata: { + proRequirement: "waived", + baaRecordId: paidRecord.id, + organizationId: paidRecord.organizationId, + userId: paidRecord.userId, + }, +}; + const paidSession = { id: "cs_paid_baa", mode: "subscription", @@ -548,6 +586,63 @@ const proSubscription = { metadata: {}, }; +describe("Signed BAA Pro waiver scope", () => { + it.each(["active", "trialing", "past_due"] as const)( + "accepts a bound waiver while the BAA is %s", + (status) => { + expect( + hasBaaProWaiver({ ...waivedSubscription, status }, paidRecord), + ).toBe(true); + }, + ); + + it.each(["proRequirement", "baaRecordId", "organizationId", "userId"])( + "rejects a waiver with mismatched %s", + (field) => { + expect( + hasBaaProWaiver( + { + ...waivedSubscription, + metadata: { + ...waivedSubscription.metadata, + [field]: "different", + }, + }, + paidRecord, + ), + ).toBe(false); + }, + ); + + it("requires a known BAA price even when the metadata claims a BAA waiver", () => { + expect( + hasBaaProWaiver( + { + ...waivedSubscription, + metadata: { ...waivedSubscription.metadata, type: "signed_baa" }, + items: { + ...waivedSubscription.items, + data: waivedSubscription.items.data.map((item) => ({ + ...item, + price: { ...item.price, id: "price_pro" }, + })), + }, + }, + paidRecord, + ), + ).toBe(false); + }); + + it.each(["canceled", "unpaid", "incomplete"] as const)( + "does not preserve the waiver when the BAA is %s", + (status) => { + expect( + hasBaaProWaiver({ ...waivedSubscription, status }, paidRecord), + ).toBe(false); + }, + ); +}); + describe("Paid BAA signing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -557,23 +652,28 @@ describe("Paid BAA signing", () => { .mockResolvedValue(proSubscription); }); - async function setupPaidSigning(record = paidRecord) { + async function setupPaidSigning( + record = paidRecord, + subscription = paidSubscription, + owner: Record = {}, + ) { mockOwner({ stripeCustomerId: "cus_pro", stripeSubscriptionId: "sub_pro", stripeSubscriptionStatus: "active", + ...owner, }); mockDb.limit.mockResolvedValueOnce([record]); - mockStripe.subscriptions.retrieve.mockResolvedValueOnce({ - id: "sub_pro", - customer: "cus_pro", - status: "active", + mockStripe.subscriptions.retrieve.mockImplementation(async (id: string) => { + if (id === subscription.id) return subscription; + if (id === proSubscription.id) return proSubscription; + throw new Error(`No such subscription: ${id}`); }); - if (record.stripeSubscriptionId) { - mockStripe.subscriptions.retrieve.mockResolvedValueOnce(paidSubscription); - } vi.mocked(generateSignedBaaPdf).mockResolvedValue(new Uint8Array([1])); - vi.mocked(sendEmail).mockResolvedValue(undefined as never); + vi.mocked(sendEmail).mockResolvedValue({ + data: { id: "email-1" }, + error: null, + }); return import("@/actions/organization/signed-baa"); } @@ -598,6 +698,165 @@ describe("Paid BAA signing", () => { expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); }); + it.each(["signPaidBaa", "purchaseSignedBaa"] as const)( + "%s signs a waived paid BAA without changing legacy Pro or charging again", + async (action) => { + const actions = await setupPaidSigning(paidRecord, waivedSubscription, { + stripeSubscriptionId: "12345", + inviteQuota: 6, + }); + await expect( + actions[action]("org-1" as never, VALID_INPUT), + ).resolves.toEqual({ success: true, emailSent: true }); + expect(mockStripe.subscriptions.retrieve).not.toHaveBeenCalledWith( + "12345", + ); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(mockStripe.paymentMethods.list).not.toHaveBeenCalled(); + expect(mockDb.update).not.toHaveBeenCalledWith(users); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: "active", + stripeSubscriptionId: paidSubscription.id, + signedAt: expect.any(Date), + }), + ); + }, + ); + + it("still requires real Pro for a paid BAA without a waiver", async () => { + const { signPaidBaa } = await setupPaidSigning( + paidRecord, + paidSubscription, + { + stripeSubscriptionId: "12345", + }, + ); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( + "active Cap Pro subscription", + ); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + }); + + it("does not use an old owner's waiver for the current organization owner", async () => { + const previousOwnerRecord = { + ...paidRecord, + userId: "former-owner" as never, + }; + const { signPaidBaa } = await setupPaidSigning( + previousOwnerRecord, + { + ...waivedSubscription, + metadata: { ...waivedSubscription.metadata, userId: "former-owner" }, + }, + { stripeSubscriptionId: "12345" }, + ); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( + "active Cap Pro subscription", + ); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + }); + + it("does not turn a pending unpaid BAA into a paid agreement through its waiver", async () => { + const { signPaidBaa } = await setupPaidSigning( + { ...paidRecord, status: "pending" }, + { + ...waivedSubscription, + latest_invoice: { status: "open" } as Stripe.Invoice, + }, + { stripeSubscriptionId: "12345" }, + ); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( + "payment is not confirmed", + ); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it.each(["canceled", "unpaid"] as const)( + "does not sign or charge a waived BAA whose subscription is %s", + async (status) => { + const { signPaidBaa } = await setupPaidSigning( + paidRecord, + { ...waivedSubscription, status }, + { stripeSubscriptionId: "12345" }, + ); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( + "payment is not confirmed", + ); + expect(mockDb.update).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + failure: "provider rejection", + response: { + data: null, + error: { name: "validation_error" as const, message: "Email rejected" }, + }, + }, + { failure: "unconfigured provider", response: undefined }, + ])( + "keeps the signed agreement retryable after $failure", + async ({ response }) => { + const { signPaidBaa } = await setupPaidSigning( + paidRecord, + waivedSubscription, + { + stripeSubscriptionId: "12345", + }, + ); + vi.mocked(sendEmail).mockResolvedValueOnce(response); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual( + { + success: true, + emailSent: false, + }, + ); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: "active", + signedAt: expect.any(Date), + }), + ); + expect(mockDb.set).not.toHaveBeenCalledWith( + expect.objectContaining({ emailSentAt: expect.any(Date) }), + ); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + }, + ); + + it("retries a waived agreement's email without changing its signing date or charging", async () => { + const signedAt = new Date("2026-09-01T19:45:00.000Z"); + const { signPaidBaa } = await setupPaidSigning( + { ...paidRecord, status: "active", signedAt }, + waivedSubscription, + { stripeSubscriptionId: "12345" }, + ); + await expect(signPaidBaa("org-1" as never, VALID_INPUT)).resolves.toEqual({ + success: true, + emailSent: true, + }); + expect(generateSignedBaaPdf).toHaveBeenCalledWith( + expect.objectContaining({ signedAt, executionId: paidRecord.id }), + ); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: `signed-baa-email-${paidRecord.id}`, + }), + ); + expect(mockStripe.subscriptions.create).not.toHaveBeenCalled(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + }); + it("keeps payment when the new signature cannot generate a PDF", async () => { const { signPaidBaa } = await setupPaidSigning(); vi.mocked(generateSignedBaaPdf).mockRejectedValueOnce(new Error("bad PNG")); @@ -624,9 +883,10 @@ describe("Paid BAA signing", () => { it("rejects a canceled paid subscription without attempting a new purchase", async () => { const { signPaidBaa } = await setupPaidSigning(); mockStripe.subscriptions.retrieve.mockReset(); - mockStripe.subscriptions.retrieve - .mockResolvedValueOnce({ id: "sub_pro", status: "active" }) - .mockResolvedValueOnce({ ...paidSubscription, status: "canceled" }); + mockStripe.subscriptions.retrieve.mockResolvedValueOnce({ + ...paidSubscription, + status: "canceled", + }); await expect(signPaidBaa("org-1" as never, VALID_INPUT)).rejects.toThrow( "payment is not confirmed", ); diff --git a/apps/web/actions/organization/signed-baa.ts b/apps/web/actions/organization/signed-baa.ts index f4f876f989..98a26f8d1f 100644 --- a/apps/web/actions/organization/signed-baa.ts +++ b/apps/web/actions/organization/signed-baa.ts @@ -16,6 +16,7 @@ import { attachPaidBaaCheckout, BAA_ENTITLED_STATUSES, ensureBaaHasPro, + hasBaaProWaiver, hasPaidBaaInvoice, isSignedBaaSubscription, } from "@/lib/baa/billing"; @@ -364,36 +365,7 @@ async function completeSignedBaa( const { user } = await getOwnerContext(organizationId); const priceId = getBaaPriceId(); - const customerId = user.stripeCustomerId; - if ( - !customerId || - !user.stripeSubscriptionId || - !ownerCanPurchaseSignedBaa(user) - ) { - throw new Error( - "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", - ); - } - - let liveProSubscription: Stripe.Subscription; - try { - liveProSubscription = await stripe().subscriptions.retrieve( - user.stripeSubscriptionId, - ); - } catch { - throw new Error( - "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", - ); - } - if ( - !CAP_PRO_STATUSES.has(liveProSubscription.status) || - isSignedBaaSubscription(liveProSubscription) - ) { - throw new Error( - "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", - ); - } - + const customerId = user.stripeCustomerId ?? ""; const [existing] = await db() .select() .from(signedBaas) @@ -425,6 +397,41 @@ async function completeSignedBaa( throw new Error("A confirmed BAA payment is required before signing."); } + const proRequirementWaived = + existing?.userId === user.id && + paidSubscription && + hasBaaProWaiver(paidSubscription, existing); + if (!proRequirementWaived) { + if ( + !customerId || + !user.stripeSubscriptionId || + !ownerCanPurchaseSignedBaa(user) + ) { + throw new Error( + "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", + ); + } + + let liveProSubscription: Stripe.Subscription; + try { + liveProSubscription = await stripe().subscriptions.retrieve( + user.stripeSubscriptionId, + ); + } catch { + throw new Error( + "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", + ); + } + if ( + !CAP_PRO_STATUSES.has(liveProSubscription.status) || + isSignedBaaSubscription(liveProSubscription) + ) { + throw new Error( + "Your organization needs an active Cap Pro subscription before adding the Signed BAA add-on.", + ); + } + } + const { signatureDataUrl, ...contractFields } = details; const recordFields = { ...contractFields, signatureData: signatureDataUrl }; @@ -489,6 +496,7 @@ async function completeSignedBaa( } } + const recordIdentity = { id: recordId, organizationId, userId: user.id }; const revertToPending = async () => { // The subscription.created webhook can associate and activate the record // mid-flight when Stripe's create response was lost, so the revert must @@ -515,7 +523,8 @@ async function completeSignedBaa( BAA_ENTITLED_STATUSES.has(recovered.status) && hasPaidBaaInvoice(recovered) ) { - if (!(await ensureBaaHasPro(user, recovered, recordId))) return; + if (!(await ensureBaaHasPro(user, recovered, recordIdentity))) + return; status = "paid"; } } catch { @@ -628,7 +637,7 @@ async function completeSignedBaa( ); } } - if (!(await ensureBaaHasPro(user, subscription, recordId))) { + if (!(await ensureBaaHasPro(user, subscription, recordIdentity))) { throw new Error( "Cap Pro ended before the BAA could be signed. The BAA subscription has been canceled; please contact support about your payment.", ); @@ -659,7 +668,7 @@ async function completeSignedBaa( let emailSent = Boolean(existing?.emailSentAt); if (!emailSent) { try { - await sendEmail({ + const delivery = await sendEmail({ email: user.email, cc: [ BAA_NOTICE_EMAIL, @@ -684,6 +693,12 @@ async function completeSignedBaa( ], idempotencyKey: `signed-baa-email-${recordId}`, }); + if (!delivery?.data?.id || delivery.error) { + throw new Error( + delivery?.error?.message ?? + "The email provider did not confirm delivery.", + ); + } await db() .update(signedBaas) .set({ emailSentAt: new Date() }) diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SignedBaaCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SignedBaaCard.tsx index a5f521c6ab..018cea81f9 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SignedBaaCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SignedBaaCard.tsx @@ -461,7 +461,7 @@ function SignedBaaCardContent() {

{isPaid ? ( - "Payment has already been received. Signing completes your Business Associate Agreement with no additional charge. Your existing $99/month BAA subscription continues on its current billing schedule, separately from Cap Pro, and ends if your Cap Pro subscription is canceled." + "Payment has already been received. Signing completes your Business Associate Agreement with no additional charge. Your existing $99/month BAA subscription continues on its current billing schedule, separately from Cap Pro." ) : ( <> By signing, you execute the Business Associate Agreement and diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 077e88796b..cc734d538d 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -10,12 +10,13 @@ import { import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; -import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { attachPaidBaaCheckout, ensureBaaHasPro, + hasBaaProWaiver, hasPaidBaaInvoice, isSignedBaaPrice, isSignedBaaSubscription, @@ -121,13 +122,30 @@ async function cancelEntitledBaaSubscriptions( subscriptions: Stripe.Subscription[], customerId: string, ) { + const waivedSubscriptionIds = subscriptions + .filter( + (subscription) => + isSignedBaaSubscription(subscription) && + subscription.metadata?.proRequirement === "waived", + ) + .map((subscription) => subscription.id); const linked = await db() - .select({ subscriptionId: signedBaas.stripeSubscriptionId }) + .select({ + id: signedBaas.id, + organizationId: signedBaas.organizationId, + userId: signedBaas.userId, + subscriptionId: signedBaas.stripeSubscriptionId, + }) .from(signedBaas) .innerJoin(users, eq(signedBaas.userId, users.id)) .where( and( - eq(users.stripeCustomerId, customerId), + or( + eq(users.stripeCustomerId, customerId), + ...(waivedSubscriptionIds.length + ? [inArray(signedBaas.stripeSubscriptionId, waivedSubscriptionIds)] + : []), + ), ne(signedBaas.status, "canceled"), ), ) @@ -151,6 +169,8 @@ async function cancelEntitledBaaSubscriptions( // billing when no entitled Pro subscription remains. for (const sub of allSubscriptions) { if (!isSignedBaaSubscription(sub)) continue; + const record = linked.find((item) => item.subscriptionId === sub.id); + if (record && hasBaaProWaiver(sub, record)) continue; if (ENTITLED_SUBSCRIPTION_STATUSES.has(sub.status)) { await stripe().subscriptions.cancel(sub.id); } @@ -226,7 +246,7 @@ async function syncSignedBaaStatus( if (!owner) { throw new Error("The BAA owner could not be found."); } - if (!(await ensureBaaHasPro(owner, subscription, record.id))) { + if (!(await ensureBaaHasPro(owner, subscription, record))) { return NextResponse.json({ received: true }); } } diff --git a/apps/web/lib/baa/billing.ts b/apps/web/lib/baa/billing.ts index 0efb4764f4..3f4081136d 100644 --- a/apps/web/lib/baa/billing.ts +++ b/apps/web/lib/baa/billing.ts @@ -33,6 +33,27 @@ export function hasPaidBaaInvoice(subscription: Stripe.Subscription) { ); } +type BaaIdentity = Pick< + typeof signedBaas.$inferSelect, + "id" | "organizationId" | "userId" +>; + +export function hasBaaProWaiver( + subscription: Stripe.Subscription, + record: BaaIdentity, +) { + return ( + BAA_ENTITLED_STATUSES.has(subscription.status) && + subscription.items?.data.some((item) => + isSignedBaaPrice(item.price?.id), + ) === true && + subscription.metadata?.proRequirement === "waived" && + subscription.metadata.baaRecordId === record.id && + subscription.metadata.organizationId === record.organizationId && + subscription.metadata.userId === record.userId + ); +} + type CheckoutOwner = { id: User.UserId; email: string; @@ -43,11 +64,12 @@ type CheckoutOwner = { export async function ensureBaaHasPro( owner: Pick, subscription: Stripe.Subscription, - recordId: string, + record: BaaIdentity, ) { if (!isSignedBaaSubscription(subscription)) { throw new Error("The subscription is not a Signed BAA."); } + if (hasBaaProWaiver(subscription, record)) return true; const proSubscription = owner.stripeSubscriptionId ? await stripe().subscriptions.retrieve(owner.stripeSubscriptionId) : null; @@ -70,7 +92,7 @@ export async function ensureBaaHasPro( .set({ status: "canceled" }) .where( and( - eq(signedBaas.id, recordId), + eq(signedBaas.id, record.id), eq(signedBaas.stripeSubscriptionId, subscription.id), ), ); @@ -285,7 +307,7 @@ export async function attachPaidBaaCheckout( // Link before checking Pro so a concurrent Pro cancellation can find this // subscription even when Checkout created a different Stripe customer. - if (!(await ensureBaaHasPro(owner, subscription, record.id))) { + if (!(await ensureBaaHasPro(owner, subscription, record))) { return null; } if (attached.status !== "pending") return attached;