Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
80 changes: 80 additions & 0 deletions apps/api/src/routes/dev-plans.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createTestUser, deleteAll } from "@/testing.js";
import { db, eq, tables } from "@llmgateway/db";

import type * as PaymentsModule from "@/routes/payments.js";
import type * as StripeModule from "@/stripe.js";

const stripeMock = vi.hoisted(() => ({
prices: {
Expand All @@ -20,6 +21,11 @@ const stripeMock = vi.hoisted(() => ({
finalizeInvoice: vi.fn(),
voidInvoice: vi.fn(),
},
checkout: {
sessions: {
create: vi.fn(),
},
},
}));

vi.mock("@/routes/payments.js", async (importOriginal) => {
Expand All @@ -30,6 +36,17 @@ vi.mock("@/routes/payments.js", async (importOriginal) => {
};
});

// ensureStripeCustomer reaches Stripe through stripe.ts's own client (its
// customers.update on the already-existing customer), which the payments mock
// above does not rewire — stub just that helper and keep the rest real.
vi.mock("@/stripe.js", async (importOriginal) => {
const original = await importOriginal<typeof StripeModule>();
return {
...original,
ensureStripeCustomer: vi.fn().mockResolvedValue("cus_dev_plan"),
};
});

const ORG_ID = "test-dev-plan-org";
const SUBSCRIPTION_ID = "sub_dev_plan_upgrade";
const originalProPriceId = process.env.STRIPE_DEV_PLAN_PRO_PRICE_ID;
Expand Down Expand Up @@ -1219,3 +1236,66 @@ describe("dev plan status billing history", () => {
expect(await getStatus()).toMatchObject({ hasBillingHistory: true });
});
});

describe("dev plan subscribe checkout", () => {
let token: string;

beforeEach(async () => {
vi.clearAllMocks();
process.env.STRIPE_DEV_PLAN_PRO_PRICE_ID = "price_pro";
token = await createTestUser();

await db.insert(tables.organization).values({
id: ORG_ID,
name: "Personal Org",
billingEmail: "admin@example.com",
stripeCustomerId: "cus_dev_plan",
kind: "devpass",
devPlan: "none",
});
await db.insert(tables.userOrganization).values({
userId: "test-user-id",
organizationId: ORG_ID,
role: "owner",
});
});

afterEach(async () => {
if (originalProPriceId === undefined) {
delete process.env.STRIPE_DEV_PLAN_PRO_PRICE_ID;
} else {
process.env.STRIPE_DEV_PLAN_PRO_PRICE_ID = originalProPriceId;
}
await db.delete(tables.transaction);
await deleteAll();
});

it("offers card and crypto on the setup-mode checkout session", async () => {
stripeMock.checkout.sessions.create.mockResolvedValue({
id: "cs_setup_001",
url: "https://checkout.stripe.test/cs_setup_001",
});

const res = await app.request("/dev-plans/subscribe", {
method: "POST",
headers: {
Cookie: token,
"Content-Type": "application/json",
},
body: JSON.stringify({ tier: "pro" }),
});

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
checkoutUrl: "https://checkout.stripe.test/cs_setup_001",
});

expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1);
const params = stripeMock.checkout.sessions.create.mock.calls[0][0];
expect(params.mode).toBe("setup");
// Both instruments must be offered: cards, and Stripe's stablecoin
// ("crypto") payment method which is saved like a card and charged for
// recurring cycles.
expect(params.payment_method_types).toEqual(["card", "crypto"]);
});
});
55 changes: 43 additions & 12 deletions apps/api/src/routes/dev-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ensureStripeCustomer,
finalizeDevPlanSetupSession,
fulfillResetPassPurchase,
getCryptoPaymentMethodDetails,
getSubscriptionPaymentConfirmation,
isDevPlanCardDedupeEnforced,
} from "@/stripe.js";
Expand Down Expand Up @@ -424,16 +425,26 @@ devPlans.openapi(subscribe, async (c) => {
try {
const stripeCustomerId = await ensureStripeCustomer(personalOrg.id);

// We use `mode: "setup"` (not "subscription") so the card is collected
// but the customer is NOT charged at checkout. After redirect we check
// the card fingerprint against existing DevPass orgs; if it conflicts
// we reject the activation without ever creating a Stripe subscription
// or charging the user. The shared metadata carries everything the
// finalize step needs to create the subscription server-side.
// We use `mode: "setup"` (not "subscription") so the payment method is
// collected but the customer is NOT charged at checkout. After redirect
// we check the payment-method fingerprint against existing DevPass orgs;
// if it conflicts we reject the activation without ever creating a
// Stripe subscription or charging the user. The shared metadata carries
// everything the finalize step needs to create the subscription
// server-side.
//
// "crypto" is Stripe's stablecoin payment method: the wallet is saved
// like a card (smart-contract authorization) and charged for recurring
// subscription cycles, so it rides the same setup-mode flow. The pinned
// SDK's literal union predates the crypto payment method — the API
// accepts it, hence the cast.
const session = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,
mode: "setup",
payment_method_types: ["card"],
payment_method_types: [
"card",
"crypto",
] as string[] as Stripe.Checkout.SessionCreateParams.PaymentMethodType[],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
success_url: `${process.env.CODE_URL ?? "http://localhost:3004"}/dashboard?setup_session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.CODE_URL ?? "http://localhost:3004"}/dashboard?canceled=true`,
metadata: {
Expand Down Expand Up @@ -616,7 +627,7 @@ devPlans.openapi(finalize, async (c) => {
{
error: "duplicate_card" as const,
message:
"This card is already associated with another DevPass account. Please use a different payment method.",
"This payment method is already associated with another DevPass account. Please use a different one.",
},
409,
);
Expand Down Expand Up @@ -2787,7 +2798,7 @@ devPlans.openapi(rotateApiKey, async (c) => {
});
});

// Get the card currently backing the DevPass subscription
// Get the payment method currently backing the DevPass subscription
const getPaymentMethod = createRoute({
method: "get",
path: "/payment-method",
Expand All @@ -2805,6 +2816,14 @@ const getPaymentMethod = createRoute({
expiryYear: z.number(),
})
.nullable(),
// Present when the subscription is backed by a stablecoin
// wallet (Stripe's "crypto" payment method) instead of a card.
crypto: z
.object({
network: z.string().nullable(),
walletAddress: z.string().nullable(),
})
.nullable(),
}),
},
},
Expand All @@ -2825,7 +2844,7 @@ devPlans.openapi(getPaymentMethod, async (c) => {
const personalOrg = await findPersonalOrg(user.id);

if (!personalOrg?.devPlanStripeSubscriptionId) {
return c.json({ card: null });
return c.json({ card: null, crypto: null });
}

const subscription = await getStripe().subscriptions.retrieve(
Expand All @@ -2835,16 +2854,27 @@ devPlans.openapi(getPaymentMethod, async (c) => {

const defaultPaymentMethod = subscription.default_payment_method;
if (!defaultPaymentMethod) {
return c.json({ card: null });
return c.json({ card: null, crypto: null });
}

const paymentMethod =
typeof defaultPaymentMethod === "string"
? await getStripe().paymentMethods.retrieve(defaultPaymentMethod)
: defaultPaymentMethod;

const cryptoDetails = getCryptoPaymentMethodDetails(paymentMethod);
if (cryptoDetails) {
return c.json({
card: null,
crypto: {
network: cryptoDetails.network ?? null,
walletAddress: cryptoDetails.wallet_address ?? null,
},
});
}

if (paymentMethod.type !== "card" || !paymentMethod.card) {
return c.json({ card: null });
return c.json({ card: null, crypto: null });
}

return c.json({
Expand All @@ -2854,6 +2884,7 @@ devPlans.openapi(getPaymentMethod, async (c) => {
expiryMonth: paymentMethod.card.exp_month,
expiryYear: paymentMethod.card.exp_year,
},
crypto: null,
});
});

Expand Down
Loading
Loading