Skip to content
Open
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
26 changes: 25 additions & 1 deletion apps/api/src/billing/http-schemas/stripe.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import { secondsInDay } from "date-fns/constants";

import { CustomerTransactionsCsvExportQuerySchema, CustomerTransactionsQuerySchema } from "./stripe.schema";
import { CustomerTransactionsCsvExportQuerySchema, CustomerTransactionsQuerySchema, PaymentMethodSchema } from "./stripe.schema";

describe("Stripe Schema", () => {
describe("PaymentMethodSchema", () => {
it("accepts payment method with card", () => {
const result = PaymentMethodSchema.parse({
type: "card",
card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2025 }
});
expect(result.type).toBe("card");
expect(result.card?.brand).toBe("visa");
});

it("accepts payment method with link", () => {
const result = PaymentMethodSchema.parse({
type: "link",
link: { email: "user@test.com" }
});
expect(result.type).toBe("link");
expect(result.link?.email).toBe("user@test.com");
});

it("rejects payment method without card or link", () => {
expect(() => PaymentMethodSchema.parse({ type: "unknown" })).toThrow("At least one of card or link must be provided");
});
});

describe("CustomerTransactionsQuerySchema", () => {
it("accepts no dates", () => {
const out = CustomerTransactionsQuerySchema.parse({});
Expand Down
92 changes: 51 additions & 41 deletions apps/api/src/billing/http-schemas/stripe.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,57 @@ export const PaymentMethodMarkAsDefaultInputSchema = z.object({
})
});

export const PaymentMethodSchema = z.object({
type: z.string(),
validated: z.boolean().optional(),
isDefault: z.boolean().optional(),
card: z
.object({
brand: z.string().nullable(),
last4: z.string().nullable(),
exp_month: z.number(),
exp_year: z.number(),
funding: z.string().nullable().optional(),
country: z.string().nullable().optional(),
network: z.string().nullable().optional(),
three_d_secure_usage: z
.object({
supported: z.boolean().nullable().optional()
})
.nullable()
.optional()
})
.nullable()
.optional(),
billing_details: z
.object({
address: z
.object({
city: z.string().nullable(),
country: z.string().nullable(),
line1: z.string().nullable(),
line2: z.string().nullable(),
postal_code: z.string().nullable(),
state: z.string().nullable()
})
.nullable()
.optional(),
email: z.string().nullable().optional(),
name: z.string().nullable().optional(),
phone: z.string().nullable().optional()
})
.optional()
});
export const PaymentMethodSchema = z
.object({
type: z.string(),
validated: z.boolean().optional(),
isDefault: z.boolean().optional(),
card: z
.object({
brand: z.string().nullable(),
last4: z.string().nullable(),
exp_month: z.number(),
exp_year: z.number(),
funding: z.string().nullable().optional(),
country: z.string().nullable().optional(),
network: z.string().nullable().optional(),
three_d_secure_usage: z
.object({
supported: z.boolean().nullable().optional()
})
.nullable()
.optional()
})
.nullable()
.optional(),
link: z
.object({
email: z.string().nullable().optional()
})
.nullable()
.optional(),
billing_details: z
.object({
address: z
.object({
city: z.string().nullable(),
country: z.string().nullable(),
line1: z.string().nullable(),
line2: z.string().nullable(),
postal_code: z.string().nullable(),
state: z.string().nullable()
})
.nullable()
.optional(),
email: z.string().nullable().optional(),
name: z.string().nullable().optional(),
phone: z.string().nullable().optional()
})
.optional()
})
.refine(data => !!(data.card || data.link), {
message: "At least one of card or link must be provided"
});

export const PaymentMethodsResponseSchema = z.object({
data: z.array(PaymentMethodSchema)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_abc123";

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
Expand All @@ -569,7 +570,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
fingerprint
card: { fingerprint }
});

await service.handlePaymentMethodAttached(event);
Expand All @@ -582,13 +583,55 @@ describe(StripeWebhookService.name, () => {
expect(stripeService.markRemotePaymentMethodAsDefault).toHaveBeenCalledWith(paymentMethodId, mockUser);
});

it("creates payment method for link type", async () => {
const { service, userRepository, paymentMethodRepository, stripeService } = setup();
const mockUser = createTestUser({ stripeCustomerId: "cus_123" });
const paymentMethodId = "pm_link_123";
const linkFingerprint = "link_abc123hash";

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(linkFingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-link-123",
userId: mockUser.id,
fingerprint: linkFingerprint,
paymentMethodId,
isDefault: true,
isValidated: false,
createdAt: new Date(),
updatedAt: new Date()
},
isNew: true
});
stripeService.markRemotePaymentMethodAsDefault.mockResolvedValue(undefined);

const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
type: "link",
link: { email: "user@test.com" }
});

await service.handlePaymentMethodAttached(event);

expect(stripeService.extractFingerprint).toHaveBeenCalled();
expect(paymentMethodRepository.upsert).toHaveBeenCalledWith({
userId: mockUser.id,
fingerprint: linkFingerprint,
paymentMethodId
});
expect(stripeService.markRemotePaymentMethodAsDefault).toHaveBeenCalledWith(paymentMethodId, mockUser);
});

it("handles duplicate webhook delivery gracefully (idempotency)", async () => {
const { service, userRepository, paymentMethodRepository, stripeService } = setup();
const mockUser = createTestUser({ stripeCustomerId: "cus_123" });
const paymentMethodId = "pm_123";
const fingerprint = "fp_abc123";

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
Expand All @@ -606,7 +649,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
fingerprint
card: { fingerprint }
});

await service.handlePaymentMethodAttached(event);
Expand All @@ -623,6 +666,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_def456";

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-456",
Expand All @@ -640,7 +684,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
fingerprint
card: { fingerprint }
});

await service.handlePaymentMethodAttached(event);
Expand All @@ -655,6 +699,7 @@ describe(StripeWebhookService.name, () => {
const fingerprint = "fp_abc123";

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(fingerprint);
paymentMethodRepository.upsert.mockResolvedValue({
paymentMethod: {
id: "local-pm-123",
Expand All @@ -673,7 +718,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: paymentMethodId,
customer: mockUser.stripeCustomerId!,
fingerprint
card: { fingerprint }
});

// Should not throw
Expand All @@ -686,7 +731,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: null,
fingerprint: "fp_abc123"
card: { fingerprint: "fp_abc123" }
});

await service.handlePaymentMethodAttached(event);
Expand All @@ -703,7 +748,7 @@ describe(StripeWebhookService.name, () => {
const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: "cus_unknown",
fingerprint: "fp_abc123"
card: { fingerprint: "fp_abc123" }
});

await service.handlePaymentMethodAttached(event);
Expand All @@ -713,15 +758,16 @@ describe(StripeWebhookService.name, () => {
});

it("returns early when fingerprint is missing", async () => {
const { service, userRepository, paymentMethodRepository } = setup();
const { service, userRepository, paymentMethodRepository, stripeService } = setup();
const mockUser = createTestUser({ stripeCustomerId: "cus_123" });

userRepository.findOneBy.mockResolvedValue(mockUser);
stripeService.extractFingerprint.mockReturnValue(undefined);

const event = createPaymentMethodAttachedEvent({
id: "pm_123",
customer: mockUser.stripeCustomerId!,
fingerprint: null
card: { fingerprint: null }
});

await service.handlePaymentMethodAttached(event);
Expand Down Expand Up @@ -837,15 +883,23 @@ describe(StripeWebhookService.name, () => {
} as Stripe.InvoicePaymentSucceededEvent;
}

function createPaymentMethodAttachedEvent(params: { id: string; customer: string | null; fingerprint: string | null }): Stripe.PaymentMethodAttachedEvent {
function createPaymentMethodAttachedEvent(params: {
id: string;
customer: string | null;
type?: string;
card?: { fingerprint: string | null };
link?: { email: string };
}): Stripe.PaymentMethodAttachedEvent {
return {
id: "evt_123",
type: "payment_method.attached",
data: {
object: {
id: params.id,
customer: params.customer,
card: params.fingerprint ? { fingerprint: params.fingerprint } : undefined
type: params.type ?? "card",
card: params.card,
link: params.link
} as Stripe.PaymentMethod
}
} as Stripe.PaymentMethodAttachedEvent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,17 +382,18 @@ export class StripeWebhookService {
return;
}

const fingerprint = paymentMethod.card?.fingerprint;
assertIsPayingUser(user);

const fingerprint = this.stripe.extractFingerprint(paymentMethod);
if (!fingerprint) {
this.logger.error({
event: "PAYMENT_METHOD_MISSING_FINGERPRINT",
paymentMethodId: paymentMethod.id
paymentMethodId: paymentMethod.id,
type: paymentMethod.type
});
return;
}

assertIsPayingUser(user);

// Use upsert for idempotency - handles Stripe webhook retries gracefully
const { paymentMethod: localPaymentMethod, isNew } = await this.paymentMethodRepository.upsert({
userId: user.id,
Expand Down Expand Up @@ -428,7 +429,7 @@ export class StripeWebhookService {
async handlePaymentMethodDetached(event: Stripe.PaymentMethodDetachedEvent) {
const paymentMethod = event.data.object;
const customerId = paymentMethod.customer || event.data.previous_attributes?.customer;
const fingerprint = paymentMethod.card?.fingerprint;
const fingerprint = this.stripe.extractFingerprint(paymentMethod);

this.logger.info({
event: "PAYMENT_METHOD_DETACHED",
Expand All @@ -440,7 +441,8 @@ export class StripeWebhookService {
if (!fingerprint) {
this.logger.warn({
event: "PAYMENT_METHOD_DETACHED_NO_FINGERPRINT",
paymentMethodId: paymentMethod.id
paymentMethodId: paymentMethod.id,
type: paymentMethod.type
});
return;
}
Expand Down
Loading
Loading