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

This commit is contained in:
root
2026-07-22 20:17:12 -04:00
parent 7ecd85e9b7
commit bcabd17220
38 changed files with 1674 additions and 96 deletions
@@ -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)