570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import Stripe from 'stripe';
|
|
import {
|
|
maskStripeKey,
|
|
StripeProvider,
|
|
validateStripeSecretKey,
|
|
} from '../../src/providers/payments/stripe.provider';
|
|
import type { StripeClient, StripePrice } from '../../src/types/payments';
|
|
|
|
const TEST_STRIPE_SECRET_KEY = ['sk', 'test', 'fixture', '1234567890'].join('_');
|
|
|
|
function createAsyncList<T>(items: T[]): AsyncIterable<T> {
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const item of items) {
|
|
yield item;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('StripeProvider', () => {
|
|
it('rejects keys with the wrong environment prefix', () => {
|
|
expect(() => validateStripeSecretKey('test', 'sk_live_wrong')).toThrow(
|
|
/must start with sk_test_/i
|
|
);
|
|
});
|
|
|
|
it('masks configured keys for logs and API responses', () => {
|
|
expect(maskStripeKey('sk_test_abcdefghijklmnopqrstuvwxyz')).toBe('sk_test_****wxyz');
|
|
});
|
|
|
|
it('syncs account, products, and prices as one catalog snapshot', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn().mockResolvedValue({ id: 'acct_123' }) },
|
|
products: {
|
|
list: vi.fn().mockReturnValue(createAsyncList([{ id: 'prod_123', object: 'product' }])),
|
|
},
|
|
prices: {
|
|
list: vi
|
|
.fn()
|
|
.mockReturnValueOnce(createAsyncList([{ id: 'price_123', object: 'price' }]))
|
|
.mockReturnValueOnce(createAsyncList([])),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.syncCatalog()).resolves.toMatchObject({
|
|
account: { id: 'acct_123' },
|
|
products: [{ id: 'prod_123' }],
|
|
prices: [{ id: 'price_123' }],
|
|
});
|
|
});
|
|
|
|
it('lists active and inactive prices so disabled prices remain visible', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: {
|
|
list: vi
|
|
.fn()
|
|
.mockReturnValueOnce(
|
|
createAsyncList([{ id: 'price_active', object: 'price', active: true }])
|
|
)
|
|
.mockReturnValueOnce(
|
|
createAsyncList([{ id: 'price_inactive', object: 'price', active: false }])
|
|
),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
const prices = await provider.listPrices();
|
|
|
|
expect(prices.map((price: StripePrice) => price.id)).toEqual([
|
|
'price_active',
|
|
'price_inactive',
|
|
]);
|
|
});
|
|
|
|
it('lists all Stripe subscriptions for bootstrap and repair imports', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
subscriptions: {
|
|
list: vi.fn().mockReturnValue(createAsyncList([{ id: 'sub_123', object: 'subscription' }])),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.listSubscriptions()).resolves.toEqual([
|
|
{ id: 'sub_123', object: 'subscription' },
|
|
]);
|
|
|
|
expect(client.subscriptions.list).toHaveBeenCalledWith({
|
|
limit: 100,
|
|
status: 'all',
|
|
});
|
|
});
|
|
|
|
it('lists all Stripe subscription items for a subscription', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
subscriptions: { list: vi.fn() },
|
|
subscriptionItems: {
|
|
list: vi.fn().mockReturnValue(
|
|
createAsyncList([
|
|
{ id: 'si_123', object: 'subscription_item', subscription: 'sub_123' },
|
|
{ id: 'si_456', object: 'subscription_item', subscription: 'sub_123' },
|
|
])
|
|
),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.listSubscriptionItems('sub_123')).resolves.toEqual([
|
|
{ id: 'si_123', object: 'subscription_item', subscription: 'sub_123' },
|
|
{ id: 'si_456', object: 'subscription_item', subscription: 'sub_123' },
|
|
]);
|
|
|
|
expect(client.subscriptionItems.list).toHaveBeenCalledWith({
|
|
limit: 100,
|
|
subscription: 'sub_123',
|
|
});
|
|
});
|
|
|
|
it('retrieves Stripe objects needed to recover refund context', async () => {
|
|
const invoicePayment = {
|
|
id: 'inpay_123',
|
|
object: 'invoice_payment',
|
|
invoice: {
|
|
id: 'in_123',
|
|
object: 'invoice',
|
|
},
|
|
payment: {
|
|
type: 'payment_intent',
|
|
payment_intent: 'pi_123',
|
|
},
|
|
};
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
paymentIntents: {
|
|
retrieve: vi.fn().mockResolvedValue({ id: 'pi_123', object: 'payment_intent' }),
|
|
},
|
|
charges: {
|
|
retrieve: vi.fn().mockResolvedValue({ id: 'ch_123', object: 'charge' }),
|
|
},
|
|
invoicePayments: {
|
|
list: vi.fn().mockReturnValue(createAsyncList([invoicePayment])),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.retrievePaymentIntent('pi_123')).resolves.toMatchObject({
|
|
id: 'pi_123',
|
|
});
|
|
await expect(provider.retrieveCharge('ch_123')).resolves.toMatchObject({
|
|
id: 'ch_123',
|
|
});
|
|
await expect(provider.retrieveInvoiceByPaymentIntent('pi_123')).resolves.toMatchObject({
|
|
id: 'in_123',
|
|
payments: {
|
|
data: [expect.objectContaining({ id: 'inpay_123' })],
|
|
},
|
|
});
|
|
|
|
expect(client.paymentIntents.retrieve).toHaveBeenCalledWith('pi_123');
|
|
expect(client.charges.retrieve).toHaveBeenCalledWith('ch_123');
|
|
expect(client.invoicePayments.list).toHaveBeenCalledWith({
|
|
limit: 1,
|
|
payment: {
|
|
type: 'payment_intent',
|
|
payment_intent: 'pi_123',
|
|
},
|
|
expand: ['data.invoice'],
|
|
});
|
|
});
|
|
|
|
it('creates, updates, and deletes products through Stripe products API', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: {
|
|
list: vi.fn(),
|
|
create: vi.fn().mockResolvedValue({ id: 'prod_new', object: 'product' }),
|
|
update: vi.fn().mockResolvedValue({ id: 'prod_new', object: 'product' }),
|
|
del: vi.fn().mockResolvedValue({ id: 'prod_new', deleted: true }),
|
|
},
|
|
prices: { list: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createProduct({
|
|
name: 'Pro',
|
|
description: null,
|
|
active: true,
|
|
metadata: { tier: 'pro' },
|
|
});
|
|
await provider.updateProduct('prod_new', {
|
|
description: null,
|
|
active: false,
|
|
});
|
|
await expect(provider.deleteProduct('prod_new')).resolves.toEqual({
|
|
id: 'prod_new',
|
|
deleted: true,
|
|
});
|
|
|
|
expect(client.products.create).toHaveBeenCalledWith({
|
|
name: 'Pro',
|
|
active: true,
|
|
metadata: { tier: 'pro' },
|
|
});
|
|
expect(client.products.update).toHaveBeenCalledWith('prod_new', {
|
|
description: '',
|
|
active: false,
|
|
});
|
|
expect(client.products.del).toHaveBeenCalledWith('prod_new');
|
|
});
|
|
|
|
it('creates and updates prices through Stripe prices API', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: {
|
|
list: vi.fn(),
|
|
create: vi.fn().mockResolvedValue({ id: 'price_new', object: 'price' }),
|
|
update: vi.fn().mockResolvedValue({ id: 'price_new', object: 'price' }),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createPrice({
|
|
productId: 'prod_123',
|
|
currency: 'usd',
|
|
unitAmount: 2000,
|
|
lookupKey: 'pro_monthly',
|
|
active: true,
|
|
recurring: { interval: 'month', intervalCount: 1 },
|
|
taxBehavior: 'exclusive',
|
|
metadata: { tier: 'pro' },
|
|
});
|
|
await provider.updatePrice('price_new', {
|
|
active: false,
|
|
lookupKey: null,
|
|
metadata: { archived: 'true' },
|
|
});
|
|
|
|
expect(client.prices.create).toHaveBeenCalledWith({
|
|
product: 'prod_123',
|
|
currency: 'usd',
|
|
unit_amount: 2000,
|
|
lookup_key: 'pro_monthly',
|
|
active: true,
|
|
recurring: { interval: 'month', interval_count: 1 },
|
|
tax_behavior: 'exclusive',
|
|
metadata: { tier: 'pro' },
|
|
});
|
|
expect(client.prices.update).toHaveBeenCalledWith('price_new', {
|
|
active: false,
|
|
lookup_key: '',
|
|
metadata: { archived: 'true' },
|
|
});
|
|
});
|
|
|
|
it('creates customers through Stripe customers API', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: {
|
|
create: vi.fn().mockResolvedValue({ id: 'cus_123', object: 'customer' }),
|
|
},
|
|
checkout: { sessions: { create: vi.fn() } },
|
|
webhooks: { constructEvent: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createCustomer({
|
|
email: 'buyer@example.com',
|
|
metadata: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
|
|
});
|
|
|
|
expect(client.customers.create).toHaveBeenCalledWith({
|
|
email: 'buyer@example.com',
|
|
metadata: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
|
|
});
|
|
});
|
|
|
|
it('lists Stripe customers for mirror syncs', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: {
|
|
list: vi.fn().mockReturnValue(
|
|
createAsyncList([
|
|
{ id: 'cus_123', object: 'customer', email: 'buyer@example.com' },
|
|
{ id: 'cus_456', object: 'customer', email: null, deleted: true },
|
|
])
|
|
),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.listCustomers()).resolves.toEqual([
|
|
{ id: 'cus_123', object: 'customer', email: 'buyer@example.com' },
|
|
{ id: 'cus_456', object: 'customer', email: null, deleted: true },
|
|
]);
|
|
|
|
expect(client.customers.list).toHaveBeenCalledWith({
|
|
limit: 100,
|
|
expand: ['data.invoice_settings.default_payment_method', 'data.default_source'],
|
|
});
|
|
});
|
|
|
|
it('creates customer portal sessions through Stripe billing portal API', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: { create: vi.fn() },
|
|
billingPortal: {
|
|
sessions: {
|
|
create: vi.fn().mockResolvedValue({
|
|
id: 'bps_123',
|
|
object: 'billing_portal.session',
|
|
url: 'https://billing.stripe.com/p/session/test_123',
|
|
}),
|
|
},
|
|
},
|
|
checkout: { sessions: { create: vi.fn() } },
|
|
webhooks: { constructEvent: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createCustomerPortalSession({
|
|
customerId: 'cus_123',
|
|
returnUrl: 'https://example.com/account',
|
|
configuration: 'bpc_123',
|
|
});
|
|
|
|
expect(client.billingPortal.sessions.create).toHaveBeenCalledWith({
|
|
customer: 'cus_123',
|
|
return_url: 'https://example.com/account',
|
|
configuration: 'bpc_123',
|
|
});
|
|
});
|
|
|
|
it('creates checkout sessions and copies metadata onto durable Stripe objects', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: { create: vi.fn() },
|
|
checkout: {
|
|
sessions: {
|
|
create: vi.fn().mockResolvedValue({
|
|
id: 'cs_test_123',
|
|
object: 'checkout.session',
|
|
url: 'https://checkout.stripe.com/c/pay/cs_test_123',
|
|
}),
|
|
},
|
|
},
|
|
webhooks: { constructEvent: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createCheckoutSession({
|
|
mode: 'subscription',
|
|
lineItems: [{ priceId: 'price_123', quantity: 1 }],
|
|
successUrl: 'https://example.com/success',
|
|
cancelUrl: 'https://example.com/cancel',
|
|
customerId: 'cus_123',
|
|
metadata: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
|
|
});
|
|
|
|
expect(client.checkout.sessions.create).toHaveBeenCalledWith({
|
|
mode: 'subscription',
|
|
line_items: [{ price: 'price_123', quantity: 1 }],
|
|
success_url: 'https://example.com/success',
|
|
cancel_url: 'https://example.com/cancel',
|
|
metadata: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
|
|
customer: 'cus_123',
|
|
subscription_data: {
|
|
metadata: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('requests Customer creation for identified one-time Checkout Sessions', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: { create: vi.fn() },
|
|
checkout: {
|
|
sessions: {
|
|
create: vi.fn().mockResolvedValue({
|
|
id: 'cs_test_123',
|
|
object: 'checkout.session',
|
|
url: 'https://checkout.stripe.com/c/pay/cs_test_123',
|
|
}),
|
|
},
|
|
},
|
|
webhooks: { constructEvent: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createCheckoutSession({
|
|
mode: 'payment',
|
|
lineItems: [{ priceId: 'price_123', quantity: 1 }],
|
|
successUrl: 'https://example.com/success',
|
|
cancelUrl: 'https://example.com/cancel',
|
|
customerEmail: 'buyer@example.com',
|
|
customerCreation: 'always',
|
|
});
|
|
|
|
expect(client.checkout.sessions.create).toHaveBeenCalledWith({
|
|
mode: 'payment',
|
|
line_items: [{ price: 'price_123', quantity: 1 }],
|
|
success_url: 'https://example.com/success',
|
|
cancel_url: 'https://example.com/cancel',
|
|
customer_email: 'buyer@example.com',
|
|
customer_creation: 'always',
|
|
});
|
|
});
|
|
|
|
it('passes idempotency keys to Stripe create requests', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: {
|
|
list: vi.fn(),
|
|
create: vi.fn().mockResolvedValue({ id: 'prod_new', object: 'product' }),
|
|
},
|
|
prices: {
|
|
list: vi.fn(),
|
|
create: vi.fn().mockResolvedValue({ id: 'price_new', object: 'price' }),
|
|
},
|
|
customers: {
|
|
create: vi.fn().mockResolvedValue({ id: 'cus_123', object: 'customer' }),
|
|
},
|
|
checkout: {
|
|
sessions: {
|
|
create: vi.fn().mockResolvedValue({ id: 'cs_test_123', object: 'checkout.session' }),
|
|
},
|
|
},
|
|
webhooks: { constructEvent: vi.fn() },
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await provider.createCustomer({
|
|
email: 'buyer@example.com',
|
|
idempotencyKey: 'insforge:test:customer:checkout-123',
|
|
});
|
|
await provider.createCheckoutSession({
|
|
mode: 'payment',
|
|
lineItems: [{ priceId: 'price_123', quantity: 1 }],
|
|
successUrl: 'https://example.com/success',
|
|
cancelUrl: 'https://example.com/cancel',
|
|
idempotencyKey: 'insforge:test:checkout_session:checkout-123',
|
|
});
|
|
await provider.createProduct({
|
|
name: 'Pro',
|
|
idempotencyKey: 'insforge:test:product:agent-123',
|
|
});
|
|
await provider.createPrice({
|
|
productId: 'prod_123',
|
|
currency: 'usd',
|
|
unitAmount: 2000,
|
|
idempotencyKey: 'insforge:test:price:agent-123',
|
|
});
|
|
|
|
expect(client.customers.create).toHaveBeenCalledWith(
|
|
{ email: 'buyer@example.com' },
|
|
{ idempotencyKey: 'insforge:test:customer:checkout-123' }
|
|
);
|
|
expect(client.checkout.sessions.create).toHaveBeenCalledWith(
|
|
{
|
|
mode: 'payment',
|
|
line_items: [{ price: 'price_123', quantity: 1 }],
|
|
success_url: 'https://example.com/success',
|
|
cancel_url: 'https://example.com/cancel',
|
|
},
|
|
{ idempotencyKey: 'insforge:test:checkout_session:checkout-123' }
|
|
);
|
|
expect(client.products.create).toHaveBeenCalledWith(
|
|
{ name: 'Pro' },
|
|
{ idempotencyKey: 'insforge:test:product:agent-123' }
|
|
);
|
|
expect(client.prices.create).toHaveBeenCalledWith(
|
|
{
|
|
product: 'prod_123',
|
|
currency: 'usd',
|
|
unit_amount: 2000,
|
|
},
|
|
{ idempotencyKey: 'insforge:test:price:agent-123' }
|
|
);
|
|
});
|
|
|
|
it('constructs webhook events with the original raw body and Stripe signature', () => {
|
|
const rawBody = Buffer.from('{"id":"evt_123"}');
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: { create: vi.fn() },
|
|
checkout: { sessions: { create: vi.fn() } },
|
|
webhooks: {
|
|
constructEvent: vi
|
|
.fn()
|
|
.mockReturnValue({ id: 'evt_123', type: 'checkout.session.completed' }),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
expect(provider.constructWebhookEvent(rawBody, 'sig_123', 'whsec_123')).toEqual({
|
|
id: 'evt_123',
|
|
type: 'checkout.session.completed',
|
|
});
|
|
expect(client.webhooks.constructEvent).toHaveBeenCalledWith(rawBody, 'sig_123', 'whsec_123');
|
|
});
|
|
|
|
it('lists, creates, and deletes webhook endpoints through Stripe webhook endpoint APIs', async () => {
|
|
const client = {
|
|
accounts: { retrieveCurrent: vi.fn() },
|
|
products: { list: vi.fn() },
|
|
prices: { list: vi.fn() },
|
|
customers: { create: vi.fn() },
|
|
checkout: { sessions: { create: vi.fn() } },
|
|
webhooks: { constructEvent: vi.fn() },
|
|
webhookEndpoints: {
|
|
list: vi
|
|
.fn()
|
|
.mockReturnValue(createAsyncList([{ id: 'we_123', object: 'webhook_endpoint' }])),
|
|
create: vi.fn().mockResolvedValue({
|
|
id: 'we_new',
|
|
object: 'webhook_endpoint',
|
|
secret: 'whsec_new',
|
|
}),
|
|
del: vi.fn().mockResolvedValue({ id: 'we_123', deleted: true }),
|
|
},
|
|
} as unknown as StripeClient;
|
|
const provider = new StripeProvider(TEST_STRIPE_SECRET_KEY, 'test', client);
|
|
|
|
await expect(provider.listWebhookEndpoints()).resolves.toEqual([
|
|
{ id: 'we_123', object: 'webhook_endpoint' },
|
|
]);
|
|
await expect(
|
|
provider.createWebhookEndpoint({
|
|
url: 'https://example.com/api/webhooks/stripe/test',
|
|
enabledEvents: ['checkout.session.completed'],
|
|
metadata: { managed_by: 'insforge' },
|
|
})
|
|
).resolves.toMatchObject({ id: 'we_new', secret: 'whsec_new' });
|
|
await provider.deleteWebhookEndpoint('we_123');
|
|
|
|
expect(client.webhookEndpoints.list).toHaveBeenCalledWith({ limit: 100 });
|
|
expect(client.webhookEndpoints.create).toHaveBeenCalledWith({
|
|
url: 'https://example.com/api/webhooks/stripe/test',
|
|
enabled_events: ['checkout.session.completed'],
|
|
api_version: Stripe.API_VERSION,
|
|
metadata: { managed_by: 'insforge' },
|
|
});
|
|
expect(client.webhookEndpoints.del).toHaveBeenCalledWith('we_123');
|
|
});
|
|
});
|