remove stripe
Build & Push / Pipeline Tests (push) Successful in 1m28s
Test / Type Check (all packages) (push) Successful in 32s
Build & Push / Build & Push Docker Image (push) Failing after 1m23s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 27s
Test / Carplace Unit Tests (push) Successful in 24s
Test / Admin Unit Tests (push) Successful in 24s
Test / Dashboard Unit Tests (push) Successful in 25s
Test / API Integration Tests (push) Successful in 49s
Build & Push / Pipeline Tests (push) Successful in 1m28s
Test / Type Check (all packages) (push) Successful in 32s
Build & Push / Build & Push Docker Image (push) Failing after 1m23s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 27s
Test / Carplace Unit Tests (push) Successful in 24s
Test / Admin Unit Tests (push) Successful in 24s
Test / Dashboard Unit Tests (push) Successful in 25s
Test / API Integration Tests (push) Successful in 49s
This commit is contained in:
@@ -8,7 +8,7 @@ describe('payment schemas edge cases', () => {
|
||||
type: 'CHARGE',
|
||||
currency: 'MAD',
|
||||
})
|
||||
expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const chargeSchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL', 'STRIPE']),
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
|
||||
@@ -139,44 +139,6 @@ 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,
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function handleStripeWebhook(event: any, rawBody: string | Buffer =
|
||||
}
|
||||
|
||||
export async function initCharge(reservationId: string, companyId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL' | 'STRIPE'; type: 'CHARGE' | 'DEPOSIT'
|
||||
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
|
||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||
@@ -132,7 +132,6 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
||||
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')
|
||||
@@ -151,24 +150,10 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
} else {
|
||||
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured')
|
||||
const result = await stripe.createCheckoutSession({
|
||||
amount,
|
||||
currency: body.currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: reservation.customer.email,
|
||||
successUrl: body.successUrl,
|
||||
cancelUrl: body.failureUrl,
|
||||
reservationId,
|
||||
companyId,
|
||||
type: body.type,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
stripeCheckoutSessionId = result.sessionId
|
||||
throw new ValidationError('Unsupported payment provider')
|
||||
}
|
||||
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId, stripeCheckoutSessionId })
|
||||
const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
|
||||
return { payment, checkoutUrl }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../services/stripeService', () => ({
|
||||
getConfigurationStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getConfigurationStatus } from '../../services/stripeService'
|
||||
import { getPaymentOptions } from './subscription.payment-config'
|
||||
|
||||
const ENV_KEYS = [
|
||||
@@ -26,7 +20,6 @@ describe('subscription payment configuration', () => {
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getConfigurationStatus).mockReturnValue({ configured: true, problems: [] })
|
||||
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key])
|
||||
})
|
||||
|
||||
@@ -44,7 +37,6 @@ describe('subscription payment configuration', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getPaymentOptions('en').methods).toEqual([
|
||||
{ method: 'STRIPE', enabled: true },
|
||||
{
|
||||
method: 'BANK_TRANSFER',
|
||||
enabled: true,
|
||||
@@ -74,6 +66,6 @@ describe('subscription payment configuration', () => {
|
||||
process.env.BANK_TRANSFER_ENABLED = 'true'
|
||||
process.env.CHECK_PAYMENT_ENABLED = 'true'
|
||||
|
||||
expect(getPaymentOptions('en').methods).toEqual([{ method: 'STRIPE', enabled: true }])
|
||||
expect(getPaymentOptions('en').methods).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ValidationError } from '../../http/errors'
|
||||
import { getConfigurationStatus as getStripeConfigurationStatus } from '../../services/stripeService'
|
||||
import type { NotificationLocale } from '../../services/notificationLocalizationService'
|
||||
|
||||
export type ManualCollectionMethod = 'BANK_TRANSFER' | 'CHECK'
|
||||
@@ -39,8 +38,7 @@ export function paymentEvidencePipelineReady() {
|
||||
}
|
||||
|
||||
export function getPaymentOptions(locale: NotificationLocale = 'en') {
|
||||
const stripeStatus = getStripeConfigurationStatus()
|
||||
const methods: Array<Record<string, unknown>> = [{ method: 'STRIPE', enabled: stripeStatus.configured }]
|
||||
const methods: Array<Record<string, unknown>> = []
|
||||
|
||||
// A manual method is unusable unless evidence can be scanned and submitted.
|
||||
// Keeping it hidden also makes the rollout fail closed when the scanner is
|
||||
|
||||
@@ -134,14 +134,7 @@ describe('subscription.service operational edges', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
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(stripe.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(stripe.createCheckoutSession).mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.test/session', sessionId: 'cs_test_123' } as never)
|
||||
vi.mocked(manualService.createCanonicalStripeCheckoutInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
|
||||
|
||||
it('rejects Stripe subscription checkout before creating sessions or invoices', async () => {
|
||||
await expect(service.checkout('company_1', {
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
@@ -149,25 +142,10 @@ describe('subscription.service operational edges', () => {
|
||||
provider: 'STRIPE',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
})).resolves.toEqual({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://checkout.stripe.test/session' })
|
||||
})).rejects.toThrow('Stripe subscription checkout is disabled')
|
||||
|
||||
expect(stripe.createCheckoutSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: 19900,
|
||||
currency: 'MAD',
|
||||
customerEmail: 'owner@example.test',
|
||||
companyId: 'company_1',
|
||||
subscriptionId: 'sub_1',
|
||||
type: 'SUBSCRIPTION',
|
||||
}))
|
||||
expect(manualService.createCanonicalStripeCheckoutInvoice).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
subscriptionId: 'sub_1',
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
amount: 19900,
|
||||
stripeCheckoutSessionId: 'cs_test_123',
|
||||
dueAt: new Date('2026-06-08T00:00:00.000Z'),
|
||||
}))
|
||||
expect(stripe.createCheckoutSession).not.toHaveBeenCalled()
|
||||
expect(manualService.createCanonicalStripeCheckoutInvoice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates the plan purchased through Stripe instead of keeping the previous subscription plan', async () => {
|
||||
|
||||
@@ -7,9 +7,7 @@ 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'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
import {
|
||||
createCanonicalStripeCheckoutInvoice,
|
||||
finalizeCanonicalOnlinePayment,
|
||||
recordCanonicalOnlinePaymentFailure,
|
||||
} from './subscription.manual.service'
|
||||
@@ -36,10 +34,9 @@ export async function getPlans() {
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
const stripeStatus = stripe.getConfigurationStatus()
|
||||
return {
|
||||
stripe: stripeStatus.configured,
|
||||
stripeProblems: stripeStatus.problems,
|
||||
stripe: false,
|
||||
stripeProblems: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,43 +268,7 @@ export async function checkout(companyId: string, body: {
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const dbPrice = await prisma.pricingConfig.findUnique({
|
||||
where: { plan_billingPeriod: { plan: body.plan, billingPeriod: body.billingPeriod } },
|
||||
})
|
||||
const amount = dbPrice?.amount ?? (PLAN_PRICES[body.plan]?.[body.billingPeriod] as any)?.[body.currency]
|
||||
if (!amount) throw new ValidationError('Invalid plan or billing period')
|
||||
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
||||
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl)
|
||||
assertAllowedPaymentRedirect(body.failureUrl)
|
||||
|
||||
const orderId = `sub-${companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
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',
|
||||
})
|
||||
|
||||
const dueAt = new Date(Date.now() + SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000)
|
||||
const invoice = await createCanonicalStripeCheckoutInvoice({
|
||||
companyId,
|
||||
subscriptionId: subscription.id,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
amount,
|
||||
currency: body.currency,
|
||||
stripeCheckoutSessionId: result.sessionId,
|
||||
dueAt,
|
||||
})
|
||||
return { invoice, checkoutUrl: result.checkoutUrl }
|
||||
throw new ValidationError('Stripe subscription checkout is disabled')
|
||||
}
|
||||
|
||||
export async function capturePaypal(companyId: string, paypalOrderId: string) {
|
||||
@@ -378,24 +339,7 @@ export async function reactivate(companyId: string, body: {
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
const sub = await repo.findByCompany(companyId)
|
||||
if (!sub) throw new ValidationError('No subscription found')
|
||||
|
||||
const allowedStatuses = ['CANCELLED', 'EXPIRED', 'SUSPENDED', 'PAST_DUE', 'PAYMENT_PENDING']
|
||||
if (!allowedStatuses.includes(sub.status)) {
|
||||
throw new ValidationError(`Cannot reactivate a subscription with status ${sub.status}`)
|
||||
}
|
||||
|
||||
await repo.setPaymentPending(sub.id)
|
||||
await repo.createEvent({
|
||||
subscriptionId: sub.id,
|
||||
companyId,
|
||||
eventType: 'subscription.reactivated',
|
||||
source: 'user',
|
||||
payload: { plan: body.plan, billingPeriod: body.billingPeriod },
|
||||
})
|
||||
|
||||
return checkout(companyId, body)
|
||||
throw new ValidationError('Stripe subscription checkout is disabled')
|
||||
}
|
||||
|
||||
// ─── Scheduled job actions ────────────────────────────────────
|
||||
|
||||
@@ -50,18 +50,18 @@ const app = createApp()
|
||||
describe('subscriptions public API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(service.getProviders).mockReturnValue({ stripe: true, stripeProblems: [] })
|
||||
vi.mocked(service.getProviders).mockReturnValue({ stripe: false, stripeProblems: [] })
|
||||
vi.mocked(service.getPlans).mockResolvedValue({ STARTER: { MONTHLY: { MAD: 9900 } } } as never)
|
||||
vi.mocked(service.getPlanFeatures).mockResolvedValue([
|
||||
{ id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 },
|
||||
] as never)
|
||||
})
|
||||
|
||||
it('GET /api/v1/subscriptions/providers exposes configured payment provider availability', async () => {
|
||||
it('GET /api/v1/subscriptions/providers reports Stripe unavailable', async () => {
|
||||
const res = await request(app).get('/api/v1/subscriptions/providers')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { stripe: true, stripeProblems: [] } })
|
||||
expect(res.body).toEqual({ data: { stripe: false, stripeProblems: [] } })
|
||||
expect(service.getProviders).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('subscriptions public e2e smoke', () => {
|
||||
expect(providers.body).toEqual({
|
||||
data: {
|
||||
stripe: false,
|
||||
stripeProblems: ['STRIPE_API_KEY is missing', 'STRIPE_WEBHOOK_SECRET is missing'],
|
||||
stripeProblems: [],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -112,11 +112,6 @@ interface CommunicationSettings {
|
||||
}>
|
||||
}
|
||||
|
||||
interface ProviderAvailability {
|
||||
stripe: boolean
|
||||
stripeProblems?: string[]
|
||||
}
|
||||
|
||||
interface PlanFeature {
|
||||
id: string
|
||||
plan: Plan
|
||||
@@ -247,9 +242,8 @@ export default function SubscriptionPage() {
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
const currency = 'MAD'
|
||||
const [selectedMethod, setSelectedMethod] = useState<CollectionMethod>('STRIPE')
|
||||
const [selectedMethod, setSelectedMethod] = useState<CollectionMethod>('BANK_TRANSFER')
|
||||
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([])
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ stripe: false })
|
||||
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
|
||||
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
|
||||
const [paying, setPaying] = useState(false)
|
||||
@@ -267,7 +261,7 @@ export default function SubscriptionPage() {
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Subscription',
|
||||
subtitle: 'Manage your plan, Stripe payment, and subscription invoices.',
|
||||
subtitle: 'Manage your plan, payment evidence, and subscription invoices.',
|
||||
trial: 'Free trial',
|
||||
remaining: 'remaining. Subscribe before it ends to keep access.',
|
||||
currentPlan: 'Current plan',
|
||||
@@ -288,8 +282,8 @@ export default function SubscriptionPage() {
|
||||
remainingDays: 'Remaining days',
|
||||
finalQuoteNote: 'Taxes and rounding are confirmed by the server quote.',
|
||||
subscribe: 'Subscribe',
|
||||
selectPlan: 'Select a plan to continue to Stripe checkout.',
|
||||
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
|
||||
selectPlan: 'Select a plan and submit payment evidence.',
|
||||
selectPayment: 'Choose bank transfer or check. The server calculates the final amount.',
|
||||
bankTransfer: 'Bank transfer',
|
||||
check: 'Check',
|
||||
creatingInvoice: 'Creating invoice…',
|
||||
@@ -347,7 +341,7 @@ export default function SubscriptionPage() {
|
||||
retry: 'Retry',
|
||||
accessUnavailable: 'Unable to verify your access right now. Please try again.',
|
||||
noInvoices: 'No invoices yet.',
|
||||
noProviderConfigured: 'Stripe is not configured.',
|
||||
noProviderConfigured: 'No manual payment method is configured.',
|
||||
providerUnavailable: 'This payment provider is not configured.',
|
||||
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
|
||||
@@ -360,7 +354,7 @@ export default function SubscriptionPage() {
|
||||
},
|
||||
fr: {
|
||||
title: 'Abonnement',
|
||||
subtitle: 'Gérez votre plan, le paiement Stripe et les factures d’abonnement.',
|
||||
subtitle: 'Gérez votre plan, les justificatifs de paiement et les factures d’abonnement.',
|
||||
trial: 'Essai gratuit',
|
||||
remaining: 'restants. Abonnez-vous avant la fin pour garder l’accès.',
|
||||
currentPlan: 'Plan actuel',
|
||||
@@ -381,8 +375,8 @@ export default function SubscriptionPage() {
|
||||
remainingDays: 'Jours restants',
|
||||
finalQuoteNote: 'Les taxes et l’arrondi sont confirmés par le devis serveur.',
|
||||
subscribe: 'S’abonner',
|
||||
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
|
||||
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
|
||||
selectPlan: 'Sélectionnez un plan et envoyez un justificatif de paiement.',
|
||||
selectPayment: 'Choisissez virement bancaire ou chèque. Le serveur calcule le montant final.',
|
||||
bankTransfer: 'Virement bancaire',
|
||||
check: 'Chèque',
|
||||
creatingInvoice: 'Création de la facture…',
|
||||
@@ -440,7 +434,7 @@ export default function SubscriptionPage() {
|
||||
retry: 'Réessayer',
|
||||
accessUnavailable: 'Impossible de vérifier votre accès pour le moment. Veuillez réessayer.',
|
||||
noInvoices: 'Aucune facture pour le moment.',
|
||||
noProviderConfigured: 'Stripe n’est pas configuré.',
|
||||
noProviderConfigured: 'Aucun mode de paiement manuel n’est configuré.',
|
||||
providerUnavailable: 'Ce prestataire de paiement n’est pas configuré.',
|
||||
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
|
||||
@@ -453,7 +447,7 @@ export default function SubscriptionPage() {
|
||||
},
|
||||
ar: {
|
||||
title: 'الاشتراك',
|
||||
subtitle: 'إدارة الخطة والدفع عبر Stripe وفواتير الاشتراك.',
|
||||
subtitle: 'إدارة الخطة وإثباتات الدفع وفواتير الاشتراك.',
|
||||
trial: 'تجربة مجانية',
|
||||
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
|
||||
currentPlan: 'الخطة الحالية',
|
||||
@@ -474,8 +468,8 @@ export default function SubscriptionPage() {
|
||||
remainingDays: 'الأيام المتبقية',
|
||||
finalQuoteNote: 'يؤكد عرض الخادم الضرائب والتقريب.',
|
||||
subscribe: 'اشتراك',
|
||||
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
|
||||
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
|
||||
selectPlan: 'اختر خطة وأرسل إثبات الدفع.',
|
||||
selectPayment: 'اختر التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
|
||||
bankTransfer: 'تحويل بنكي',
|
||||
check: 'شيك',
|
||||
creatingInvoice: 'جارٍ إنشاء الفاتورة…',
|
||||
@@ -533,7 +527,7 @@ export default function SubscriptionPage() {
|
||||
retry: 'إعادة المحاولة',
|
||||
accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.',
|
||||
noInvoices: 'لا توجد فواتير حتى الآن.',
|
||||
noProviderConfigured: 'Stripe غير مهيأ.',
|
||||
noProviderConfigured: 'لا توجد طريقة دفع يدوية مهيأة.',
|
||||
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
|
||||
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
|
||||
@@ -591,16 +585,14 @@ export default function SubscriptionPage() {
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
apiFetch<ProviderAvailability>('/subscriptions/providers'),
|
||||
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
|
||||
apiFetch<CommunicationSettings>('/subscriptions/communication-settings'),
|
||||
fetchPlanData(),
|
||||
])
|
||||
.then(([sub, inv, availability, options, settings]) => {
|
||||
setProviderAvailability(availability)
|
||||
.then(([sub, inv, options, settings]) => {
|
||||
setPaymentOptions(options.methods ?? [])
|
||||
setCommunicationSettings(settings)
|
||||
const firstEnabled = options.methods?.find((option) => option.enabled)
|
||||
const firstEnabled = options.methods?.find((option) => option.enabled && option.method !== 'STRIPE')
|
||||
if (firstEnabled) setSelectedMethod(firstEnabled.method)
|
||||
if (sub) {
|
||||
setSubscription(sub)
|
||||
@@ -635,14 +627,6 @@ export default function SubscriptionPage() {
|
||||
}
|
||||
}, [canViewPage, fetchPlanData])
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription?.status !== 'ACTIVE') return
|
||||
if (PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) return
|
||||
if (selectedMethod !== 'STRIPE') return
|
||||
const manualOption = paymentOptions.find((option) => option.enabled && option.method !== 'STRIPE')
|
||||
if (manualOption) setSelectedMethod(manualOption.method)
|
||||
}, [paymentOptions, selectedMethod, selectedPlan, subscription])
|
||||
|
||||
if (verificationError) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center px-6">
|
||||
@@ -693,85 +677,63 @@ export default function SubscriptionPage() {
|
||||
if (isPlanUpgrade && billingPeriod !== subscription?.billingPeriod) {
|
||||
throw new Error(copy.selectHigherPlan)
|
||||
}
|
||||
if (isPlanUpgrade && selectedMethod === 'STRIPE') {
|
||||
throw new Error(copy.upgradeManualOnly)
|
||||
}
|
||||
if (selectedMethod !== 'STRIPE') {
|
||||
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
|
||||
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
|
||||
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
|
||||
if (fileError) throw new Error(fileError)
|
||||
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
|
||||
setCheckoutIdempotencyKey(idempotencyKey)
|
||||
const result = isPlanUpgrade
|
||||
? await (async () => {
|
||||
const quoted = await apiFetch<UpgradeQuoteResult>('/subscriptions/upgrade-quotes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetPlan: selectedPlan, requestType: 'IMMEDIATE_PRORATED', idempotencyKey }),
|
||||
})
|
||||
const accepted = await apiFetch<UpgradeAcceptResult>(`/subscriptions/upgrade-requests/${quoted.request.id}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ method: selectedMethod, acceptedTermsVersion: 'subscription-upgrade-terms-v1', idempotencyKey }),
|
||||
})
|
||||
if (!accepted.invoice || !accepted.instructions) throw new Error('Upgrade payment invoice was not created.')
|
||||
return { invoice: accepted.invoice, instructions: accepted.instructions }
|
||||
})()
|
||||
: await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
|
||||
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
|
||||
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
|
||||
const fileError = validatePaymentEvidenceFiles(evidenceFiles)
|
||||
if (fileError) throw new Error(fileError)
|
||||
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
|
||||
setCheckoutIdempotencyKey(idempotencyKey)
|
||||
const result = isPlanUpgrade
|
||||
? await (async () => {
|
||||
const quoted = await apiFetch<UpgradeQuoteResult>('/subscriptions/upgrade-quotes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
|
||||
body: JSON.stringify({ targetPlan: selectedPlan, requestType: 'IMMEDIATE_PRORATED', idempotencyKey }),
|
||||
})
|
||||
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
|
||||
|
||||
const key = submissionIdempotencyKey ?? crypto.randomUUID()
|
||||
setSubmissionIdempotencyKey(key)
|
||||
const submission = await apiFetch<ManualPaymentSubmission>(
|
||||
`/subscriptions/invoices/${result.invoice.id}/manual-payment-submissions`,
|
||||
{
|
||||
const accepted = await apiFetch<UpgradeAcceptResult>(`/subscriptions/upgrade-requests/${quoted.request.id}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ method: selectedMethod, acceptedTermsVersion: 'subscription-upgrade-terms-v1', idempotencyKey }),
|
||||
})
|
||||
if (!accepted.invoice || !accepted.instructions) throw new Error('Upgrade payment invoice was not created.')
|
||||
return { invoice: accepted.invoice, instructions: accepted.instructions }
|
||||
})()
|
||||
: await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
|
||||
},
|
||||
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
|
||||
})
|
||||
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
|
||||
|
||||
const key = submissionIdempotencyKey ?? crypto.randomUUID()
|
||||
setSubmissionIdempotencyKey(key)
|
||||
const submission = await apiFetch<ManualPaymentSubmission>(
|
||||
`/subscriptions/invoices/${result.invoice.id}/manual-payment-submissions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ method: selectedMethod, submittedReference: paymentReference, idempotencyKey: key }),
|
||||
},
|
||||
)
|
||||
let current = submission
|
||||
for (const file of evidenceFiles) {
|
||||
const form = new FormData()
|
||||
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
|
||||
form.append('file', file)
|
||||
const document = await apiFetch<ManualPaymentDocument>(
|
||||
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
|
||||
{ method: 'POST', body: form },
|
||||
)
|
||||
let current = submission
|
||||
for (const file of evidenceFiles) {
|
||||
const form = new FormData()
|
||||
form.append('kind', selectedMethod === 'CHECK' ? 'CHECK_COPY' : 'BANK_TRANSFER_RECEIPT')
|
||||
form.append('file', file)
|
||||
const document = await apiFetch<ManualPaymentDocument>(
|
||||
`/subscriptions/manual-payment-submissions/${submission.id}/documents`,
|
||||
{ method: 'POST', body: form },
|
||||
)
|
||||
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
|
||||
setPaymentSubmission(current)
|
||||
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
|
||||
}
|
||||
const submitted = await apiFetch<ManualPaymentSubmission>(
|
||||
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
setPaymentSubmission(submitted)
|
||||
setManualCheckout(null)
|
||||
setEvidenceFiles([])
|
||||
setCheckoutIdempotencyKey(null)
|
||||
setSubmissionIdempotencyKey(null)
|
||||
setPaying(false)
|
||||
return
|
||||
current = { ...current, documents: [...current.documents.filter((item) => item.id !== document.id), document] }
|
||||
setPaymentSubmission(current)
|
||||
if (document.scanStatus !== 'CLEAN') throw new Error(`Evidence scan status: ${document.scanStatus}`)
|
||||
}
|
||||
if (!providerAvailability.stripe) throw new Error(copy.providerUnavailable)
|
||||
const currentUrl = new URL(window.location.href)
|
||||
currentUrl.search = ''
|
||||
currentUrl.hash = ''
|
||||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
plan: selectedPlan,
|
||||
billingPeriod,
|
||||
currency,
|
||||
provider: 'STRIPE',
|
||||
successUrl: `${currentUrl.toString()}?payment=success`,
|
||||
failureUrl: `${currentUrl.toString()}?payment=failed`,
|
||||
}),
|
||||
})
|
||||
window.location.href = result.checkoutUrl
|
||||
const submitted = await apiFetch<ManualPaymentSubmission>(
|
||||
`/subscriptions/manual-payment-submissions/${submission.id}/submit`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
setPaymentSubmission(submitted)
|
||||
setManualCheckout(null)
|
||||
setEvidenceFiles([])
|
||||
setCheckoutIdempotencyKey(null)
|
||||
setSubmissionIdempotencyKey(null)
|
||||
setPaying(false)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setPaying(false)
|
||||
@@ -896,7 +858,6 @@ export default function SubscriptionPage() {
|
||||
const isActiveSubscription = subscription?.status === 'ACTIVE'
|
||||
const isPlanUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] > PLAN_RANK[subscription.plan])
|
||||
const isInvalidActiveUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan])
|
||||
const activeUpgradeManualRequired = Boolean(isPlanUpgradeSelection && selectedMethod === 'STRIPE')
|
||||
const upgradeProration = buildUpgradeProrationPreview({ subscription, selectedPlan, billingPeriod, planPrices, currency })
|
||||
const payableAmount = upgradeProration?.subtotal ?? planPrice
|
||||
const manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
|
||||
@@ -1014,16 +975,9 @@ export default function SubscriptionPage() {
|
||||
|
||||
{/* Plan selector + checkout */}
|
||||
<div className="card p-6 space-y-6">
|
||||
{paymentOptions.length > 0 && !paymentOptions.some((option) => option.enabled) ? (
|
||||
{!paymentOptions.some((option) => option.enabled && option.method !== 'STRIPE') ? (
|
||||
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
|
||||
{copy.noProviderConfigured}
|
||||
{providerAvailability.stripeProblems && providerAvailability.stripeProblems.length > 0 ? (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{providerAvailability.stripeProblems.map((problem) => (
|
||||
<li key={problem}>{problem}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
@@ -1034,9 +988,6 @@ export default function SubscriptionPage() {
|
||||
{isInvalidActiveUpgradeSelection ? (
|
||||
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.selectHigherPlan}</p>
|
||||
) : null}
|
||||
{activeUpgradeManualRequired ? (
|
||||
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.upgradeManualOnly}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Billing period toggle */}
|
||||
@@ -1130,13 +1081,11 @@ export default function SubscriptionPage() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2 dark:text-zinc-300">{copy.paymentProvider}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{paymentOptions.filter((option) => option.enabled).map((option) => (
|
||||
{paymentOptions.filter((option) => option.enabled && option.method !== 'STRIPE').map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.method}
|
||||
disabled={isPlanUpgradeSelection && option.method === 'STRIPE'}
|
||||
onClick={() => {
|
||||
if (isPlanUpgradeSelection && option.method === 'STRIPE') return
|
||||
setSelectedMethod(option.method)
|
||||
setManualCheckout(null)
|
||||
setManualPaymentRequestNumber(null)
|
||||
@@ -1148,9 +1097,9 @@ export default function SubscriptionPage() {
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${
|
||||
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200' : 'border-slate-200 text-slate-600 dark:border-zinc-700 dark:text-zinc-300'
|
||||
} ${isPlanUpgradeSelection && option.method === 'STRIPE' ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
}`}
|
||||
>
|
||||
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
|
||||
{option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1174,18 +1123,15 @@ export default function SubscriptionPage() {
|
||||
disabled={
|
||||
paying
|
||||
|| loading
|
||||
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|
||||
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled && option.method !== 'STRIPE')
|
||||
|| isInvalidActiveUpgradeSelection
|
||||
|| activeUpgradeManualRequired
|
||||
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
|
||||
}
|
||||
className="btn-primary px-8 py-3"
|
||||
>
|
||||
{paying
|
||||
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
|
||||
: selectedMethod === 'STRIPE'
|
||||
? (isActiveSubscription ? copy.planUpgrade : copy.subscribeNow)
|
||||
: (isActiveSubscription ? copy.planUpgrade : copy.submitPaymentEvidence)}
|
||||
? copy.submittingReview
|
||||
: (isActiveSubscription ? copy.planUpgrade : copy.submitPaymentEvidence)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user