add stripe
Build & Push / Pipeline Tests (push) Failing after 1m6s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 56s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Push / Pipeline Tests (push) Failing after 1m6s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 56s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
This commit is contained in:
@@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import { capturePaypalSchema, chargeSchema, manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas'
|
||||
|
||||
describe('payment schemas edge cases', () => {
|
||||
it('defaults charge and manual payment currency/type while rejecting unsupported providers', () => {
|
||||
it('defaults charge and manual payment currency/type while accepting supported providers', () => {
|
||||
expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({
|
||||
provider: 'PAYPAL',
|
||||
type: 'CHARGE',
|
||||
currency: 'MAD',
|
||||
})
|
||||
expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false)
|
||||
expect(chargeSchema.safeParse({ provider: 'UNKNOWN', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false)
|
||||
expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
|
||||
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
|
||||
})
|
||||
|
||||
@@ -13,17 +13,26 @@ vi.mock('../../services/paypalService', () => ({
|
||||
refundCapture: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/stripeService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
createCheckoutSession: vi.fn(),
|
||||
refundPaymentIntent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./payment.repo', () => ({
|
||||
findByCompany: vi.fn(),
|
||||
findByReservation: vi.fn(),
|
||||
findByAmanpay: vi.fn(),
|
||||
findByPaypal: vi.fn(),
|
||||
findByStripeCheckoutSession: vi.fn(),
|
||||
findByPaypalForCompany: vi.fn(),
|
||||
findPaymentOrThrow: vi.fn(),
|
||||
findReservationOrThrow: vi.fn(),
|
||||
findReservation: vi.fn(),
|
||||
markPaymentSucceeded: vi.fn(),
|
||||
markStripePaymentSucceeded: vi.fn(),
|
||||
markPaymentFailed: vi.fn(),
|
||||
markStripePaymentFailed: vi.fn(),
|
||||
incrementReservationPaid: vi.fn(),
|
||||
createPayment: vi.fn(),
|
||||
updatePaypalCapture: vi.fn(),
|
||||
@@ -36,11 +45,13 @@ vi.mock('./payment.repo', () => ({
|
||||
import { ConflictError, ValidationError } from '../../http/errors'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './payment.repo'
|
||||
import {
|
||||
capturePaypal,
|
||||
handleAmanpayWebhook,
|
||||
handlePaypalWebhook,
|
||||
handleStripeWebhook,
|
||||
initCharge,
|
||||
recordManualPayment,
|
||||
refundPayment,
|
||||
@@ -126,6 +137,44 @@ describe('payment.service', () => {
|
||||
expect(repo.createPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a Stripe Checkout Session for an outstanding rental charge', async () => {
|
||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue(reservation as never)
|
||||
vi.mocked(stripe.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
|
||||
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', status: 'PENDING' } as never)
|
||||
|
||||
const result = await initCharge('reservation_1', 'company_1', {
|
||||
provider: 'STRIPE',
|
||||
type: 'CHARGE',
|
||||
currency: 'MAD',
|
||||
successUrl: 'https://app.example/success',
|
||||
failureUrl: 'https://app.example/failure',
|
||||
})
|
||||
|
||||
expect(stripe.createCheckoutSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: 1000,
|
||||
currency: 'MAD',
|
||||
orderId: 'reservation_1-CHARGE-1780913700000',
|
||||
description: 'Rental: Dacia Duster',
|
||||
customerEmail: 'nora@example.com',
|
||||
successUrl: 'https://app.example/success',
|
||||
cancelUrl: 'https://app.example/failure',
|
||||
reservationId: 'reservation_1',
|
||||
companyId: 'company_1',
|
||||
type: 'CHARGE',
|
||||
}))
|
||||
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
reservationId: 'reservation_1',
|
||||
amount: 1000,
|
||||
status: 'PENDING',
|
||||
type: 'CHARGE',
|
||||
paymentProvider: 'STRIPE',
|
||||
stripeCheckoutSessionId: 'cs_test_123',
|
||||
}))
|
||||
expect(result).toEqual({ payment: { id: 'payment_1', status: 'PENDING' }, checkoutUrl: 'https://checkout.stripe.test/session' })
|
||||
})
|
||||
|
||||
it('allows an outstanding deposit when the rental invoice is fully paid', async () => {
|
||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||
...reservation,
|
||||
@@ -195,19 +244,23 @@ describe('payment.service', () => {
|
||||
expect(repo.setReservationPaidAmount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
||||
it('applies paid AmanPay, PayPal, and Stripe webhook events to the matching records', async () => {
|
||||
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never)
|
||||
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never)
|
||||
vi.mocked(repo.findByStripeCheckoutSession).mockResolvedValue({ id: 'payment_3', reservationId: 'reservation_3', amount: 600, type: 'CHARGE', status: 'PENDING' } as never)
|
||||
|
||||
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
||||
await handlePaypalWebhook({ id: 'paypal_event_1', event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
||||
await handlePaypalWebhook({ id: 'paypal_event_2', event_type: 'PAYMENT.CAPTURE.DENIED', resource: { id: 'paypal_capture_2' } })
|
||||
await handleStripeWebhook({ id: 'evt_1', type: 'checkout.session.completed', data: { object: { id: 'cs_test_123', payment_intent: 'pi_test_123' } } })
|
||||
|
||||
expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_1')
|
||||
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 450)
|
||||
expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_2')
|
||||
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_2', 500)
|
||||
expect(repo.markPaymentFailed).toHaveBeenCalledWith({ paypalCaptureId: 'paypal_capture_2' })
|
||||
expect(repo.markStripePaymentSucceeded).toHaveBeenCalledWith('payment_3', 'pi_test_123')
|
||||
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_3', 600)
|
||||
})
|
||||
|
||||
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
||||
@@ -249,4 +302,24 @@ describe('payment.service', () => {
|
||||
expect(repo.setReservationRefunded).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' })
|
||||
})
|
||||
|
||||
it('refunds Stripe payments by PaymentIntent', async () => {
|
||||
vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({
|
||||
id: 'payment_1',
|
||||
reservationId: 'reservation_1',
|
||||
status: 'SUCCEEDED',
|
||||
amount: 1000,
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'STRIPE',
|
||||
stripePaymentIntentId: 'pi_test_123',
|
||||
} as never)
|
||||
vi.mocked(repo.setPaymentRefunded).mockResolvedValue({ id: 'payment_1', status: 'REFUNDED' } as never)
|
||||
|
||||
const result = await refundPayment('reservation_1', 'payment_1', 'company_1', undefined, 'Customer request')
|
||||
|
||||
expect(stripe.refundPaymentIntent).toHaveBeenCalledWith('pi_test_123', 1000, 'Customer request')
|
||||
expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', false)
|
||||
expect(repo.setReservationRefunded).toHaveBeenCalledWith('reservation_1')
|
||||
expect(result).toEqual({ id: 'payment_1', status: 'REFUNDED' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,9 +205,11 @@ export async function refundPayment(reservationId: string, paymentId: string, co
|
||||
if (payment.paymentProvider === 'AMANPAY') {
|
||||
if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID')
|
||||
await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason)
|
||||
} else {
|
||||
} else if (payment.paymentProvider === 'PAYPAL') {
|
||||
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
||||
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
|
||||
} else {
|
||||
throw new ValidationError('Manual payments must be refunded outside the online gateway flow')
|
||||
}
|
||||
|
||||
const isPartial = refundAmount < payment.amount
|
||||
|
||||
@@ -61,6 +61,31 @@ describe('subscription.repo edge queries and mutations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies a purchased plan when activating a paid subscription invoice', async () => {
|
||||
await repo.activateSubscription('sub_1', new Date('2026-07-01T12:00:00.000Z'), {
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
})
|
||||
|
||||
expect(prisma.subscription.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sub_1' },
|
||||
data: {
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date('2026-06-01T12:00:00.000Z'),
|
||||
currentPeriodEnd: new Date('2026-07-01T12:00:00.000Z'),
|
||||
paymentPendingSince: null,
|
||||
paymentDueAt: null,
|
||||
pastDueSince: null,
|
||||
suspendedAt: null,
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sets payment pending due dates seven days from the mutation time', async () => {
|
||||
await repo.setPaymentPending('sub_1')
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@ export function findInvoiceByPaypal(captureId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoiceByStripe(sessionId: string) {
|
||||
return prisma.subscriptionInvoice.findFirst({
|
||||
where: { stripeCheckoutSessionId: sessionId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) {
|
||||
return prisma.subscriptionInvoice.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId },
|
||||
@@ -70,10 +77,17 @@ export async function findOrCreateSubscription(
|
||||
})
|
||||
}
|
||||
|
||||
export function activateSubscription(id: string, periodEnd: Date) {
|
||||
export function activateSubscription(
|
||||
id: string,
|
||||
periodEnd: Date,
|
||||
purchasedPlan?: { plan?: string | null; billingPeriod?: string | null; currency?: string | null },
|
||||
) {
|
||||
return prisma.subscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(purchasedPlan?.plan ? { plan: purchasedPlan.plan as any } : {}),
|
||||
...(purchasedPlan?.billingPeriod ? { billingPeriod: purchasedPlan.billingPeriod as any } : {}),
|
||||
...(purchasedPlan?.currency ? { currency: purchasedPlan.currency } : {}),
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: periodEnd,
|
||||
@@ -193,15 +207,24 @@ export function updatePlan(companyId: string, data: { plan: any; billingPeriod:
|
||||
export function createInvoice(data: {
|
||||
companyId: string
|
||||
subscriptionId: string
|
||||
requestedPlan?: string | null
|
||||
requestedBillingPeriod?: string | null
|
||||
amount: number
|
||||
currency: string
|
||||
paymentProvider: string
|
||||
amanpayTransactionId?: string | null
|
||||
paypalCaptureId?: string | null
|
||||
stripeCheckoutSessionId?: string | null
|
||||
dueAt?: Date | null
|
||||
}) {
|
||||
return prisma.subscriptionInvoice.create({
|
||||
data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any },
|
||||
data: {
|
||||
...data,
|
||||
requestedPlan: data.requestedPlan as any,
|
||||
requestedBillingPeriod: data.requestedBillingPeriod as any,
|
||||
status: 'PENDING',
|
||||
paymentProvider: data.paymentProvider as any,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ok } from '../../http/respond'
|
||||
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as service from './subscription.service'
|
||||
import {
|
||||
checkoutSchema,
|
||||
@@ -62,6 +63,17 @@ webhookRouter.post('/webhooks/paypal', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
webhookRouter.post('/webhooks/stripe', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = getRawBodyString(req)
|
||||
const signature = (req.headers['stripe-signature'] as string) ?? ''
|
||||
if (!stripe.isConfigured()) return res.status(401).json({ error: 'invalid_signature' })
|
||||
const event = stripe.constructWebhookEvent(rawBody, signature)
|
||||
await service.handleStripeWebhook(event, rawBody)
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal capture (auth but no subscription check) ──────────
|
||||
|
||||
router.post('/capture-paypal', requireCompanyAuth, requireTenant, requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('subscription.schemas edge contracts', () => {
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
provider: 'PAYPAL',
|
||||
provider: 'STRIPE',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
}
|
||||
@@ -41,7 +41,7 @@ describe('subscription.schemas edge contracts', () => {
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'EUR',
|
||||
provider: 'AMANPAY',
|
||||
provider: 'PAYPAL',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
}).success).toBe(false)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
|
||||
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO'])
|
||||
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
|
||||
const providerEnum = z.enum(['AMANPAY', 'PAYPAL'])
|
||||
const providerEnum = z.enum(['STRIPE'])
|
||||
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock('../../services/paypalService', () => ({
|
||||
createOrder: vi.fn(),
|
||||
captureOrder: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../services/stripeService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
createCheckoutSession: vi.fn(),
|
||||
}))
|
||||
vi.mock('./subscription.repo', () => ({
|
||||
findByCompany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -26,6 +30,7 @@ vi.mock('./subscription.repo', () => ({
|
||||
createEvent: vi.fn(),
|
||||
findInvoiceByAmanpay: vi.fn(),
|
||||
findInvoiceByPaypal: vi.fn(),
|
||||
findInvoiceByStripe: vi.fn(),
|
||||
findInvoiceByPaypalForCompany: vi.fn(),
|
||||
findOrCreateSubscription: vi.fn(),
|
||||
createInvoice: vi.fn(),
|
||||
@@ -49,9 +54,18 @@ vi.mock('./subscription.repo', () => ({
|
||||
setSuspended: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../security/webhookIdempotency', () => ({
|
||||
getWebhookEventId: vi.fn((provider: string, event: any) => event.id ?? event.transaction_id ?? `${provider}_event`),
|
||||
processWebhookOnce: vi.fn(async ({ handle }: { handle: () => Promise<unknown> }) => {
|
||||
const result = await handle()
|
||||
return { duplicate: false, result }
|
||||
}),
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './subscription.repo'
|
||||
import * as service from './subscription.service'
|
||||
|
||||
@@ -111,41 +125,82 @@ describe('subscription.service operational edges', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('creates AmanPay checkout invoices with webhook metadata and due dates', async () => {
|
||||
it('creates Stripe checkout invoices with session metadata and due dates', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findUnique).mockResolvedValue({ amount: 19900 } as never)
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ email: 'owner@example.test', name: 'Atlas Cars' } as never)
|
||||
vi.mocked(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example.test/checkout', transactionId: 'txn_1' } as never)
|
||||
vi.mocked(stripe.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
|
||||
vi.mocked(repo.createInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
|
||||
|
||||
await expect(service.checkout('company_1', {
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
provider: 'AMANPAY',
|
||||
provider: 'STRIPE',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
})).resolves.toEqual({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://pay.example.test/checkout' })
|
||||
})).resolves.toEqual({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://checkout.stripe.test/session' })
|
||||
|
||||
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(stripe.createCheckoutSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: 19900,
|
||||
currency: 'MAD',
|
||||
customerEmail: 'owner@example.test',
|
||||
customerName: 'Atlas Cars',
|
||||
webhookUrl: 'https://api.example.test/api/v1/subscriptions/webhooks/amanpay',
|
||||
companyId: 'company_1',
|
||||
subscriptionId: 'sub_1',
|
||||
type: 'SUBSCRIPTION',
|
||||
}))
|
||||
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
subscriptionId: 'sub_1',
|
||||
requestedPlan: 'GROWTH',
|
||||
requestedBillingPeriod: 'MONTHLY',
|
||||
amount: 19900,
|
||||
paymentProvider: 'AMANPAY',
|
||||
amanpayTransactionId: 'txn_1',
|
||||
paymentProvider: 'STRIPE',
|
||||
amanpayTransactionId: null,
|
||||
paypalCaptureId: null,
|
||||
stripeCheckoutSessionId: 'cs_test_123',
|
||||
dueAt: new Date('2026-06-08T00:00:00.000Z'),
|
||||
}))
|
||||
})
|
||||
|
||||
it('activates the plan purchased through Stripe instead of keeping the previous subscription plan', async () => {
|
||||
vi.mocked(repo.findInvoiceByStripe).mockResolvedValue({
|
||||
id: 'invoice_1',
|
||||
subscriptionId: 'sub_1',
|
||||
status: 'PENDING',
|
||||
requestedPlan: 'PRO',
|
||||
requestedBillingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
} as never)
|
||||
vi.mocked(repo.findById).mockResolvedValue({
|
||||
id: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
plan: 'STARTER',
|
||||
billingPeriod: 'MONTHLY',
|
||||
status: 'ACTIVE',
|
||||
} as never)
|
||||
|
||||
await service.handleStripeWebhook({
|
||||
id: 'evt_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { id: 'cs_test_123' } },
|
||||
})
|
||||
|
||||
expect(repo.markInvoicePaid).toHaveBeenCalledWith('invoice_1')
|
||||
expect(repo.activateSubscription).toHaveBeenCalledWith(
|
||||
'sub_1',
|
||||
new Date('2027-06-01T00:00:00.000Z'),
|
||||
{ plan: 'PRO', billingPeriod: 'ANNUAL', currency: 'MAD' },
|
||||
)
|
||||
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'subscription.activated',
|
||||
payload: expect.objectContaining({ invoiceId: 'invoice_1', purchasedPlan: 'PRO' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps paid PayPal capture idempotent and avoids provider capture', async () => {
|
||||
vi.mocked(repo.findInvoiceByPaypalForCompany).mockResolvedValue({ status: 'PAID' } as never)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '../../lib/prisma'
|
||||
import { ValidationError } from '../../http/errors'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './subscription.repo'
|
||||
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
@@ -29,7 +30,11 @@ export async function getPlans() {
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
|
||||
const stripeStatus = stripe.getConfigurationStatus()
|
||||
return {
|
||||
stripe: stripeStatus.configured,
|
||||
stripeProblems: stripeStatus.problems,
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlanFeatures() {
|
||||
@@ -104,7 +109,11 @@ export async function startTrial(
|
||||
|
||||
// ─── Payment success (shared by all providers) ───────────────
|
||||
|
||||
async function handlePaymentSuccess(subscriptionId: string, invoiceId: string) {
|
||||
async function handlePaymentSuccess(subscriptionId: string, invoiceId: string, purchasedPlan?: {
|
||||
plan?: string | null
|
||||
billingPeriod?: string | null
|
||||
currency?: string | null
|
||||
}) {
|
||||
const sub = await repo.findById(subscriptionId)
|
||||
if (!sub) return
|
||||
|
||||
@@ -115,14 +124,15 @@ async function handlePaymentSuccess(subscriptionId: string, invoiceId: string) {
|
||||
status: 'succeeded',
|
||||
})
|
||||
|
||||
const periodEnd = addPeriod(new Date(), sub.billingPeriod)
|
||||
await repo.activateSubscription(subscriptionId, periodEnd)
|
||||
const billingPeriod = purchasedPlan?.billingPeriod ?? sub.billingPeriod
|
||||
const periodEnd = addPeriod(new Date(), billingPeriod)
|
||||
await repo.activateSubscription(subscriptionId, periodEnd, purchasedPlan)
|
||||
await repo.createEvent({
|
||||
subscriptionId,
|
||||
companyId: sub.companyId,
|
||||
eventType: sub.status === 'TRIALING' ? 'trial.converted' : 'subscription.activated',
|
||||
source: 'webhook',
|
||||
payload: { invoiceId, periodEnd },
|
||||
payload: { invoiceId, periodEnd, purchasedPlan: purchasedPlan?.plan ?? sub.plan },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,7 +178,11 @@ async function applyAmanpayWebhook(event: any) {
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const invoice = await repo.findInvoiceByAmanpay(transactionId)
|
||||
if (!invoice || invoice.status === 'PAID') return // idempotent
|
||||
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
|
||||
await handlePaymentSuccess(invoice.subscriptionId, invoice.id, {
|
||||
plan: invoice.requestedPlan,
|
||||
billingPeriod: invoice.requestedBillingPeriod,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
} else if (status === 'FAILED' || status === 'DECLINED') {
|
||||
const invoice = await repo.findInvoiceByAmanpay(transactionId)
|
||||
if (!invoice || invoice.status === 'PAID') return
|
||||
@@ -181,7 +195,11 @@ async function applyPaypalWebhook(event: any) {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await repo.findInvoiceByPaypal(captureId)
|
||||
if (!invoice || invoice.status === 'PAID') return // idempotent
|
||||
await handlePaymentSuccess(invoice.subscriptionId, invoice.id)
|
||||
await handlePaymentSuccess(invoice.subscriptionId, invoice.id, {
|
||||
plan: invoice.requestedPlan,
|
||||
billingPeriod: invoice.requestedBillingPeriod,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
} else if (event.event_type === 'PAYMENT.CAPTURE.DENIED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await repo.findInvoiceByPaypal(captureId)
|
||||
@@ -190,6 +208,24 @@ async function applyPaypalWebhook(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStripeWebhook(event: any) {
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const sessionId = event.data?.object?.id as string
|
||||
const invoice = await repo.findInvoiceByStripe(sessionId)
|
||||
if (!invoice || invoice.status === 'PAID') return
|
||||
await handlePaymentSuccess(invoice.subscriptionId, invoice.id, {
|
||||
plan: invoice.requestedPlan,
|
||||
billingPeriod: invoice.requestedBillingPeriod,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
} else if (event.type === 'checkout.session.expired') {
|
||||
const sessionId = event.data?.object?.id as string
|
||||
const invoice = await repo.findInvoiceByStripe(sessionId)
|
||||
if (!invoice || invoice.status === 'PAID') return
|
||||
await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'checkout_session_expired')
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||
return processWebhookOnce({
|
||||
provider: 'amanpay:subscriptions',
|
||||
@@ -210,11 +246,21 @@ export async function handlePaypalWebhook(event: any, rawBody: string | Buffer =
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleStripeWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||
return processWebhookOnce({
|
||||
provider: 'stripe:subscriptions',
|
||||
providerEventId: String(event.id),
|
||||
eventType: String(event.type ?? 'unknown'),
|
||||
rawBody,
|
||||
handle: () => applyStripeWebhook(event),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Checkout ─────────────────────────────────────────────────
|
||||
|
||||
export async function checkout(companyId: string, body: {
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const dbPrice = await prisma.pricingConfig.findUnique({
|
||||
@@ -228,36 +274,31 @@ export async function checkout(companyId: string, body: {
|
||||
|
||||
const orderId = `sub-${companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
let stripeCheckoutSessionId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured on this platform')
|
||||
const result = await amanpay.createCheckout({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
customerEmail: company.email, customerName: company.name,
|
||||
successUrl: body.successUrl, failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured on this platform')
|
||||
const result = await paypal.createOrder({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
returnUrl: body.successUrl, cancelUrl: body.failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
|
||||
const result = await stripe.createCheckoutSession({
|
||||
amount, currency: body.currency, orderId, description,
|
||||
customerEmail: company.email,
|
||||
successUrl: body.successUrl,
|
||||
cancelUrl: body.failureUrl,
|
||||
companyId,
|
||||
subscriptionId: subscription.id,
|
||||
type: 'SUBSCRIPTION',
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
stripeCheckoutSessionId = result.sessionId
|
||||
|
||||
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
|
||||
const invoice = await repo.createInvoice({
|
||||
companyId, subscriptionId: subscription.id, amount, currency: body.currency,
|
||||
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, dueAt,
|
||||
companyId, subscriptionId: subscription.id,
|
||||
requestedPlan: body.plan,
|
||||
requestedBillingPeriod: body.billingPeriod,
|
||||
amount, currency: body.currency,
|
||||
paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId, dueAt,
|
||||
})
|
||||
return { invoice, checkoutUrl }
|
||||
}
|
||||
@@ -268,8 +309,13 @@ export async function capturePaypal(companyId: string, paypalOrderId: string) {
|
||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
await repo.updateInvoicePaypal(invoice.id, captureId)
|
||||
const periodEnd = addPeriod(new Date(), invoice.subscription.billingPeriod)
|
||||
await repo.activateSubscription(invoice.subscriptionId, periodEnd)
|
||||
const billingPeriod = invoice.requestedBillingPeriod ?? invoice.subscription.billingPeriod
|
||||
const periodEnd = addPeriod(new Date(), billingPeriod)
|
||||
await repo.activateSubscription(invoice.subscriptionId, periodEnd, {
|
||||
plan: invoice.requestedPlan,
|
||||
billingPeriod: invoice.requestedBillingPeriod,
|
||||
currency: invoice.currency,
|
||||
})
|
||||
await repo.createEvent({
|
||||
subscriptionId: invoice.subscriptionId,
|
||||
companyId,
|
||||
@@ -322,7 +368,7 @@ export async function resume(companyId: string) {
|
||||
|
||||
export async function reactivate(companyId: string, body: {
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL'
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const sub = await repo.findByCompany(companyId)
|
||||
|
||||
Reference in New Issue
Block a user