From c9cbe479aa46114c10fba8f4c5c64ece058377b1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 25 May 2026 03:12:19 -0400 Subject: [PATCH] update subscription algorithm --- B2B_SUBSCRIPTION_EXECUTION_PLAN.md | 1340 +++++++++++++++++ apps/admin/next.config.js | 7 + .../src/app/dashboard/audit-logs/page.tsx | 2 +- apps/admin/src/app/dashboard/billing/page.tsx | 14 +- .../src/app/dashboard/companies/[id]/page.tsx | 222 ++- .../src/app/dashboard/companies/page.tsx | 5 +- apps/admin/src/app/dashboard/layout.tsx | 1 + apps/admin/src/app/dashboard/pricing/page.tsx | 224 +++ apps/admin/src/app/dashboard/renters/page.tsx | 5 +- .../src/app/dashboard/site-config/page.tsx | 4 +- apps/admin/src/components/I18nProvider.tsx | 3 + apps/api/src/index.ts | 33 + apps/api/src/modules/admin/admin.repo.ts | 12 + apps/api/src/modules/admin/admin.routes.ts | 76 + apps/api/src/modules/admin/admin.schemas.ts | 14 +- apps/api/src/modules/admin/admin.service.ts | 19 + .../api/src/modules/auth/auth.company.repo.ts | 2 +- .../src/modules/auth/auth.company.schemas.ts | 2 +- .../src/modules/auth/auth.renter.schemas.ts | 2 +- .../src/modules/companies/company.schemas.ts | 4 +- .../src/modules/payments/payment.schemas.ts | 4 +- .../src/modules/payments/payment.service.ts | 2 +- apps/api/src/modules/site/site.schemas.ts | 2 +- apps/api/src/modules/site/site.service.ts | 2 +- .../subscriptions/subscription.entitlement.ts | 47 + .../subscriptions/subscription.policy.ts | 51 + .../subscriptions/subscription.repo.ts | 318 +++- .../subscriptions/subscription.routes.ts | 54 +- .../subscriptions/subscription.schemas.ts | 38 +- .../subscriptions/subscription.service.ts | 421 +++++- .../src/app/(dashboard)/billing/page.tsx | 14 +- .../src/app/(dashboard)/subscription/page.tsx | 30 +- .../ForgotPasswordPageClient.tsx | 20 +- .../src/app/sign-up/[[...sign-up]]/page.tsx | 10 +- .../app/(public)/pricing/PricingClient.tsx | 118 +- .../src/app/renter/profile/page.tsx | 6 +- command_Create_admin_account.md | 62 + docker-compose.dev.yml | 2 + .../migration.sql | 21 + .../migration.sql | 66 + packages/database/prisma/schema.prisma | 106 +- packages/types/src/api.ts | 20 +- 42 files changed, 3098 insertions(+), 307 deletions(-) create mode 100644 B2B_SUBSCRIPTION_EXECUTION_PLAN.md create mode 100644 apps/admin/src/app/dashboard/pricing/page.tsx create mode 100644 apps/api/src/modules/subscriptions/subscription.entitlement.ts create mode 100644 apps/api/src/modules/subscriptions/subscription.policy.ts create mode 100644 command_Create_admin_account.md create mode 100644 packages/database/prisma/migrations/20260525000000_add_pricing_config/migration.sql create mode 100644 packages/database/prisma/migrations/20260525130000_b2b_subscription_lifecycle/migration.sql diff --git a/B2B_SUBSCRIPTION_EXECUTION_PLAN.md b/B2B_SUBSCRIPTION_EXECUTION_PLAN.md new file mode 100644 index 0000000..0b7f673 --- /dev/null +++ b/B2B_SUBSCRIPTION_EXECUTION_PLAN.md @@ -0,0 +1,1340 @@ +# B2B Subscription Execution Plan + +## 1. Objective + +Build a reliable B2B subscription management system that supports: + +- Free trials +- Paid subscriptions +- Payment pending states +- Grace periods +- Payment retry logic +- Suspension +- Cancellation +- Reactivation +- Admin overrides +- Auditable subscription lifecycle events +- Entitlement-based access control + +The system must avoid ambiguous subscription states and must separate billing status from product access. + +--- + +## 2. Guiding Principles + +### 2.1 Subscription Status Is Not Access + +A subscription status describes the billing lifecycle. + +Access should be determined by an entitlement layer. + +Example: + +```text +subscription.status = "past_due" +entitlement.access_level = "limited" +``` + +Do not scatter billing checks throughout the product. + +--- + +### 2.2 Events Are the Source of Truth + +Every important lifecycle action must create an immutable event. + +Examples: + +```text +subscription.created +trial.started +trial.ended +payment.succeeded +payment.failed +subscription.suspended +subscription.canceled +subscription.reactivated +``` + +The current subscription state may be stored directly for performance, but every change must be explainable through historical events. + +--- + +### 2.3 Webhooks Are Unreliable Until Reconciled + +Billing provider webhooks may be delayed, duplicated, or arrive out of order. + +The system must support: + +- Idempotent webhook processing +- Event deduplication +- Provider reconciliation jobs +- Manual admin review + +--- + +## 3. Core Status Model + +Use the following subscription statuses: + +```text +trialing +active +payment_pending +past_due +suspended +canceled +expired +paused +``` + +Optional advanced statuses: + +```text +refunded +chargeback +``` + +Avoid adding more statuses unless there is a real operational need. Status bloat is how billing systems become folklore. + +--- + +## 4. Status Definitions + +| Status | Meaning | +|---|---| +| `trialing` | Customer is in a free or paid trial period. | +| `active` | Customer has a valid paid subscription. | +| `payment_pending` | Payment is expected but has not completed. | +| `past_due` | Payment is overdue after the pending window. | +| `suspended` | Access is blocked due to unresolved billing issue. | +| `canceled` | Subscription has been canceled and will not renew. | +| `expired` | Trial or subscription ended without renewal. | +| `paused` | Subscription is intentionally paused. | + +--- + +## 5. Recommended B2B Policy + +### 5.1 Trial Policy + +Default policy: + +```text +Trial length: 14 days +Payment method required: Yes +Trial eligibility: One trial per company or billing account +Trial conversion: Automatic +``` + +Trial rules: + +- A customer may start one trial per company workspace. +- A valid payment method should be required before trial activation. +- Trial start and end dates must be stored. +- Trial users should receive notifications before conversion. +- Trial abuse checks should be applied at account and company level. + +Recommended fields: + +```json +{ + "trial_start_at": "2026-05-25T00:00:00Z", + "trial_end_at": "2026-06-08T00:00:00Z", + "trial_used": true, + "payment_method_required": true +} +``` + +--- + +### 5.2 Trial Ending Policy + +At trial end: + +| Condition | Result | +|---|---| +| Payment succeeds | Move to `active` | +| Payment fails | Move to `payment_pending` | +| No payment method exists | Move to `expired` or require payment method before trial starts | + +Recommended rule: + +```text +If payment method is required, no payment method means trial cannot start. +``` + +This is stricter, but cleaner. + +--- + +### 5.3 Active Subscription Policy + +An active subscription means: + +- Payment is valid +- Current billing period is open +- Customer has access based on purchased plan +- Renewal should occur automatically + +Fields: + +```json +{ + "status": "active", + "current_period_start": "2026-06-08T00:00:00Z", + "current_period_end": "2026-07-08T00:00:00Z", + "cancel_at_period_end": false +} +``` + +--- + +### 5.4 Payment Pending Policy + +Payment pending begins when payment is required but incomplete. + +Triggers: + +- Trial conversion payment failed +- Renewal payment failed +- Upgrade payment requires confirmation +- Payment provider marks payment as incomplete +- Bank transfer or invoice payment is awaiting settlement + +Default B2B policy: + +```text +Payment pending timeout: 7 days +Access during payment pending: Full access +Notifications: Day 0, Day 3, Day 6 +``` + +B2B payments often involve finance teams, approvals, cards expiring, invoice routing, and other thrilling artifacts of corporate civilization. + +--- + +### 5.5 Past Due Policy + +After the payment pending window expires: + +```text +payment_pending → past_due +``` + +Default policy: + +```text +Past due duration: 7 days +Access: Limited access +Admin visibility: Required +Customer notification: Required +``` + +Limited access may include: + +- Read-only access +- No new projects +- No exports +- No premium actions +- Existing data remains accessible + +--- + +### 5.6 Suspension Policy + +After the past due window expires: + +```text +past_due → suspended +``` + +Default policy: + +```text +Suspension starts: 14 days after first failed payment +Access: Blocked or read-only +Data retention: Continue retaining account data +Admin notification: Required +``` + +Suspended accounts should not be deleted. + +Suspension means: + +- Billing issue is unresolved +- Customer can still update payment method +- Customer can still contact support +- Admins can manually extend access if needed + +--- + +### 5.7 Cancellation Policy + +After the suspension window expires: + +```text +suspended → canceled +``` + +Default B2B cancellation policy: + +```text +Cancel after: 30 days unpaid +Access: Removed +Renewal: Disabled +Data: Retained according to data retention policy +``` + +Cancellation should be final for that subscription instance, but the customer may create a new subscription or reactivate if allowed. + +--- + +## 6. Full Lifecycle Flow + +```text +none + ↓ +trialing + ↓ +active + ↓ +payment_pending + ↓ +past_due + ↓ +suspended + ↓ +canceled +``` + +Alternative trial expiration flow: + +```text +none + ↓ +trialing + ↓ +expired +``` + +Reactivation flow: + +```text +payment_pending / past_due / suspended / canceled + ↓ +payment succeeds + ↓ +active +``` + +--- + +## 7. Transition Table + +| Current Status | Event | New Status | Notes | +|---|---|---|---| +| `none` | Trial started | `trialing` | Customer begins trial | +| `none` | Paid subscription started | `payment_pending` | First payment required | +| `payment_pending` | Payment succeeded | `active` | Grant paid access | +| `trialing` | Trial ended and payment succeeded | `active` | Convert trial | +| `trialing` | Trial ended and payment failed | `payment_pending` | Start recovery flow | +| `trialing` | Trial ended without payment method | `expired` | No access | +| `active` | Renewal payment succeeded | `active` | Extend period | +| `active` | Renewal payment failed | `payment_pending` | Start dunning | +| `payment_pending` | Payment succeeded | `active` | Restore normal billing | +| `payment_pending` | Timeout reached | `past_due` | Payment unresolved | +| `past_due` | Payment succeeded | `active` | Restore access | +| `past_due` | Timeout reached | `suspended` | Restrict access | +| `suspended` | Payment succeeded | `active` | Restore access | +| `suspended` | Timeout reached | `canceled` | End subscription | +| `active` | User cancels | `active` with `cancel_at_period_end = true` | Access until period end | +| `active` | Admin cancels immediately | `canceled` | Used for fraud, abuse, or special cases | +| `canceled` | Customer reactivates | `payment_pending` | Payment required | +| `expired` | Customer subscribes | `payment_pending` | Payment required | +| `paused` | Resume date reached | `active` | Resume subscription | + +--- + +## 8. Timeout Policy + +Recommended B2B timeline: + +| Day | Status | Access | Action | +|---:|---|---|---| +| 0 | `payment_pending` | Full | Payment fails, notify billing owner | +| 3 | `payment_pending` | Full | Retry payment, send reminder | +| 6 | `payment_pending` | Full | Final warning before past due | +| 7 | `past_due` | Limited | Restrict risky actions | +| 14 | `suspended` | Read-only or blocked | Notify admins and billing owner | +| 30 | `canceled` | No access | Cancel subscription | + +Recommended constants: + +```json +{ + "payment_pending_timeout_days": 7, + "past_due_timeout_days": 7, + "suspension_timeout_days": 16, + "cancel_after_days_unpaid": 30 +} +``` + +--- + +## 9. Payment Retry Policy + +Use a configurable retry schedule. + +Recommended retry schedule: + +```json +{ + "retry_schedule_days": [0, 3, 6, 10, 14], + "max_retry_attempts": 5 +} +``` + +Retry rules: + +- Retry immediately after first failure. +- Retry again after 3 days. +- Retry again before moving to past due. +- Retry again before suspension. +- Retry one final time before cancellation. +- Stop retries if the customer cancels. +- Stop retries if invoice is manually voided. +- Stop retries if subscription is canceled. + +--- + +## 10. Access Control Policy + +Access must be derived through entitlements. + +### Access Levels + +```text +full +limited +read_only +none +``` + +### Mapping + +| Subscription Status | Access Level | +|---|---| +| `trialing` | `full` | +| `active` | `full` | +| `payment_pending` | `full` | +| `past_due` | `limited` | +| `suspended` | `read_only` or `none` | +| `canceled` | `none` | +| `expired` | `none` | +| `paused` | `read_only` | + +### Entitlement Decision Example + +```json +{ + "workspace_id": "workspace_123", + "subscription_id": "sub_123", + "subscription_status": "past_due", + "access_level": "limited", + "reason": "payment_overdue", + "valid_until": "2026-06-22T00:00:00Z" +} +``` + +--- + +## 11. Data Model + +### 11.1 Subscriptions Table + +```sql +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL, + customer_id UUID NOT NULL, + plan_id UUID NOT NULL, + + status TEXT NOT NULL, + + billing_interval TEXT NOT NULL, + current_period_start TIMESTAMP, + current_period_end TIMESTAMP, + + trial_start_at TIMESTAMP, + trial_end_at TIMESTAMP, + + payment_pending_since TIMESTAMP, + payment_due_at TIMESTAMP, + + past_due_since TIMESTAMP, + suspended_at TIMESTAMP, + + cancel_at_period_end BOOLEAN DEFAULT FALSE, + canceled_at TIMESTAMP, + ended_at TIMESTAMP, + + retry_count INTEGER DEFAULT 0, + max_retry_count INTEGER DEFAULT 5, + + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +--- + +### 11.2 Subscription Events Table + +```sql +CREATE TABLE subscription_events ( + id UUID PRIMARY KEY, + subscription_id UUID NOT NULL, + workspace_id UUID NOT NULL, + + event_type TEXT NOT NULL, + source TEXT NOT NULL, + payload JSONB NOT NULL, + + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +--- + +### 11.3 Invoices Table + +```sql +CREATE TABLE invoices ( + id UUID PRIMARY KEY, + subscription_id UUID NOT NULL, + workspace_id UUID NOT NULL, + + provider_invoice_id TEXT, + status TEXT NOT NULL, + + amount_due INTEGER NOT NULL, + amount_paid INTEGER DEFAULT 0, + currency TEXT NOT NULL, + + due_at TIMESTAMP, + paid_at TIMESTAMP, + failed_at TIMESTAMP, + voided_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +--- + +### 11.4 Payment Attempts Table + +```sql +CREATE TABLE payment_attempts ( + id UUID PRIMARY KEY, + invoice_id UUID NOT NULL, + subscription_id UUID NOT NULL, + + provider_payment_id TEXT, + status TEXT NOT NULL, + + failure_code TEXT, + failure_message TEXT, + + attempted_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +--- + +### 11.5 Entitlements Table + +```sql +CREATE TABLE entitlements ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL, + subscription_id UUID NOT NULL, + + feature_key TEXT NOT NULL, + access_level TEXT NOT NULL, + + starts_at TIMESTAMP NOT NULL, + ends_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +--- + +## 12. Event Catalog + +### Subscription Events + +```text +subscription.created +subscription.updated +subscription.activated +subscription.payment_pending +subscription.past_due +subscription.suspended +subscription.canceled +subscription.expired +subscription.reactivated +subscription.paused +subscription.resumed +``` + +### Trial Events + +```text +trial.started +trial.ending_soon +trial.ended +trial.converted +trial.expired +``` + +### Payment Events + +```text +payment.started +payment.succeeded +payment.failed +payment.requires_action +payment.retry_scheduled +payment.retry_failed +payment.retry_exhausted +``` + +### Invoice Events + +```text +invoice.created +invoice.finalized +invoice.paid +invoice.payment_failed +invoice.voided +invoice.refunded +``` + +### Risk Events + +```text +chargeback.created +chargeback.resolved +fraud.review_required +admin.override_created +``` + +--- + +## 13. Notification Plan + +### 13.1 Trial Notifications + +| Timing | Message | +|---|---| +| Trial start | Welcome and trial end date | +| 7 days before trial end | Reminder | +| 3 days before trial end | Conversion reminder | +| 1 day before trial end | Final reminder | +| Trial converted | Payment receipt and activation | +| Trial expired | Upgrade prompt | + +--- + +### 13.2 Payment Failure Notifications + +| Timing | Recipient | Message | +|---|---|---| +| Day 0 | Billing owner | Payment failed | +| Day 3 | Billing owner + admins | Payment retry failed | +| Day 6 | Billing owner + admins | Final warning before past due | +| Day 7 | Billing owner + admins | Account is past due | +| Day 14 | Billing owner + admins | Account suspended | +| Day 30 | Billing owner + admins | Subscription canceled | + +Every notification should include: + +- Company name +- Plan name +- Amount due +- Due date +- Payment update link +- Consequence of non-payment +- Support contact + +--- + +## 14. Admin Console Requirements + +Admins must be able to: + +- View subscription status +- View payment history +- View invoice history +- View retry attempts +- View lifecycle events +- Extend trial +- Extend grace period +- Retry payment +- Cancel immediately +- Cancel at period end +- Suspend account +- Reactivate account +- Grant temporary access +- Change plan +- Add internal notes + +All admin actions must create audit events. + +Audit record: + +```json +{ + "admin_id": "admin_123", + "action": "extend_grace_period", + "reason": "Enterprise customer requested invoice processing time", + "previous_state": "past_due", + "new_state": "payment_pending", + "created_at": "2026-06-15T00:00:00Z" +} +``` + +--- + +## 15. Webhook Processing Requirements + +Webhook processor must support: + +- Signature verification +- Idempotency keys +- Event deduplication +- Out-of-order event handling +- Retry-safe processing +- Dead-letter queue +- Manual replay + +Webhook events to handle: + +```text +customer.subscription.created +customer.subscription.updated +customer.subscription.deleted +invoice.created +invoice.finalized +invoice.paid +invoice.payment_failed +payment_intent.succeeded +payment_intent.payment_failed +charge.dispute.created +charge.refunded +``` + +Processing rule: + +```text +Never trust webhook order. +Always compare provider timestamp, local state, and event history. +``` + +--- + +## 16. Scheduled Jobs + +Create scheduled jobs for: + +| Job | Frequency | Purpose | +|---|---:|---| +| Trial ending reminder job | Daily | Notify customers before trial ends | +| Trial expiration job | Hourly | Expire or convert ended trials | +| Payment retry job | Hourly | Retry failed invoices | +| Payment pending timeout job | Hourly | Move stale pending payments to past due | +| Past due timeout job | Hourly | Move past due subscriptions to suspended | +| Suspension timeout job | Daily | Cancel long-unpaid subscriptions | +| Provider reconciliation job | Daily | Compare local state with billing provider | +| Entitlement sync job | Hourly | Ensure access matches subscription state | + +--- + +## 17. API Requirements + +### Create Trial + +```http +POST /subscriptions/trials +``` + +Request: + +```json +{ + "workspace_id": "workspace_123", + "plan_id": "plan_pro", + "payment_method_id": "pm_123" +} +``` + +Response: + +```json +{ + "subscription_id": "sub_123", + "status": "trialing", + "trial_end_at": "2026-06-08T00:00:00Z" +} +``` + +--- + +### Start Paid Subscription + +```http +POST /subscriptions +``` + +Request: + +```json +{ + "workspace_id": "workspace_123", + "plan_id": "plan_pro", + "payment_method_id": "pm_123" +} +``` + +--- + +### Cancel Subscription + +```http +POST /subscriptions/{subscription_id}/cancel +``` + +Request: + +```json +{ + "mode": "period_end", + "reason": "customer_requested" +} +``` + +--- + +### Reactivate Subscription + +```http +POST /subscriptions/{subscription_id}/reactivate +``` + +Request: + +```json +{ + "payment_method_id": "pm_123" +} +``` + +--- + +### Get Entitlements + +```http +GET /workspaces/{workspace_id}/entitlements +``` + +Response: + +```json +{ + "workspace_id": "workspace_123", + "access_level": "full", + "features": [ + { + "feature_key": "advanced_reports", + "enabled": true + } + ] +} +``` + +--- + +## 18. Policy Configuration + +Create a centralized subscription policy config. + +```json +{ + "trial": { + "enabled": true, + "duration_days": 14, + "requires_payment_method": true, + "one_trial_per_workspace": true, + "one_trial_per_company": true + }, + "payment": { + "payment_pending_timeout_days": 7, + "retry_schedule_days": [0, 3, 6, 10, 14], + "max_retry_attempts": 5 + }, + "past_due": { + "timeout_days": 7, + "access_level": "limited" + }, + "suspension": { + "cancel_after_days": 16, + "access_level": "read_only" + }, + "cancellation": { + "default_customer_cancel_mode": "period_end", + "admin_cancel_mode": "immediate" + }, + "access": { + "trialing": "full", + "active": "full", + "payment_pending": "full", + "past_due": "limited", + "suspended": "read_only", + "canceled": "none", + "expired": "none", + "paused": "read_only" + }, + "notifications": { + "trial_ending_days_before": [7, 3, 1], + "payment_failure_days_after": [0, 3, 6], + "past_due_days_after": [7], + "suspension_days_after": [14], + "cancellation_days_after": [30] + } +} +``` + +--- + +## 19. Implementation Phases + +## Phase 1: Foundation + +### Goals + +- Define status model +- Create subscription tables +- Create event table +- Create basic policy config +- Implement basic subscription service + +### Deliverables + +- Subscription schema +- Event schema +- Subscription status enum +- Policy config +- Subscription creation endpoint +- Trial creation endpoint + +### Acceptance Criteria + +- A workspace can start a trial. +- A workspace can start a paid subscription. +- Subscription status is persisted. +- Every status change creates an event. + +--- + +## Phase 2: Billing Provider Integration + +### Goals + +- Integrate billing provider +- Create customer records +- Create subscriptions +- Track invoices +- Track payment attempts + +### Deliverables + +- Billing provider adapter +- Webhook processor +- Invoice model +- Payment attempt model +- Payment success handling +- Payment failure handling + +### Acceptance Criteria + +- Successful payment activates subscription. +- Failed payment moves subscription to `payment_pending`. +- Webhooks are idempotent. +- Duplicate webhooks do not duplicate events. + +--- + +## Phase 3: Trial Conversion + +### Goals + +- Convert trials automatically +- Handle trial expiration +- Notify users before trial end + +### Deliverables + +- Trial ending reminder job +- Trial conversion job +- Trial expiration handling +- Trial notification templates + +### Acceptance Criteria + +- Trial converts to `active` after successful payment. +- Trial moves to `payment_pending` after failed payment. +- Trial moves to `expired` if payment is not available. +- Trial ending reminders are sent on schedule. + +--- + +## Phase 4: Payment Recovery + +### Goals + +- Implement dunning flow +- Retry failed payments +- Enforce payment pending timeout +- Enforce past due timeout + +### Deliverables + +- Payment retry job +- Payment pending timeout job +- Past due timeout job +- Notification schedule +- Admin visibility for failed payments + +### Acceptance Criteria + +- Failed payments are retried on schedule. +- `payment_pending` becomes `past_due` after timeout. +- `past_due` becomes `suspended` after timeout. +- Customers and admins receive notifications. + +--- + +## Phase 5: Entitlements + +### Goals + +- Separate subscription status from access +- Create entitlement service +- Enforce access levels in product + +### Deliverables + +- Entitlement model +- Entitlement service +- Access decision API +- Feature flag integration +- Middleware or guard layer + +### Acceptance Criteria + +- `active` and `trialing` users receive full access. +- `past_due` users receive limited access. +- `suspended` users receive read-only or no access. +- Product code does not directly hardcode billing statuses. + +--- + +## Phase 6: Cancellation and Reactivation + +### Goals + +- Support customer cancellation +- Support admin cancellation +- Support reactivation +- Support cancel-at-period-end + +### Deliverables + +- Cancel subscription endpoint +- Reactivate subscription endpoint +- Period-end cancellation job +- Admin cancellation flow + +### Acceptance Criteria + +- Customer cancellation defaults to period end. +- Admin cancellation can happen immediately. +- Reactivation requires successful payment. +- Canceled subscriptions do not renew. + +--- + +## Phase 7: Admin Console + +### Goals + +- Give support and finance teams operational control +- Add audit logging for all manual actions + +### Deliverables + +- Subscription detail page +- Invoice history +- Payment attempt history +- Event timeline +- Manual retry action +- Extend trial action +- Extend grace period action +- Suspend action +- Reactivate action +- Cancel action + +### Acceptance Criteria + +- Admins can inspect complete subscription history. +- Admin actions are audited. +- High-risk actions require a reason. +- Manual overrides do not silently mutate state. + +--- + +## Phase 8: Reconciliation and Hardening + +### Goals + +- Prevent billing drift +- Detect inconsistent states +- Make webhook processing resilient + +### Deliverables + +- Provider reconciliation job +- Dead-letter queue +- Webhook replay tool +- State consistency checks +- Alerting + +### Acceptance Criteria + +- Local subscription state matches billing provider state. +- Failed webhooks are visible and replayable. +- Inconsistent states generate alerts. +- Reconciliation does not break valid admin overrides. + +--- + +## 20. State Invariants + +The system must enforce these rules: + +```text +A canceled subscription cannot renew. +A canceled subscription cannot become active without reactivation. +A subscription cannot be both active and canceled. +A trial cannot be active after trial_end_at. +A payment success must be idempotent. +A failed payment must not duplicate payment attempts. +A workspace cannot have multiple active subscriptions for the same product unless explicitly allowed. +Entitlements must be recalculated after every subscription status change. +Every manual override must create an audit event. +``` + +--- + +## 21. Monitoring and Alerts + +Track these metrics: + +```text +trial_started_count +trial_conversion_rate +payment_failed_count +payment_recovered_count +payment_pending_count +past_due_count +suspended_count +canceled_count +reactivated_count +webhook_failure_count +webhook_duplicate_count +reconciliation_mismatch_count +``` + +Alert on: + +```text +Webhook processing failures +Spike in payment failures +Subscriptions stuck in payment_pending +Subscriptions stuck in past_due +Provider/local state mismatch +Entitlement sync failures +Unexpected cancellation spike +``` + +--- + +## 22. Testing Plan + +### Unit Tests + +Test: + +- Status transitions +- Policy config +- Retry schedule +- Entitlement mapping +- Cancellation rules +- Reactivation rules + +### Integration Tests + +Test: + +- Payment success webhook +- Payment failure webhook +- Duplicate webhook +- Out-of-order webhook +- Trial conversion +- Payment retry +- Suspension +- Cancellation + +### End-to-End Tests + +Test: + +```text +Start trial → trial ends → payment succeeds → active +Start trial → trial ends → payment fails → payment_pending → past_due → suspended → canceled +Active subscription → renewal fails → payment_pending → payment succeeds → active +Active subscription → user cancels → active until period end → canceled +Suspended subscription → payment succeeds → active +``` + +--- + +## 23. Rollout Plan + +### Step 1: Internal Testing + +- Enable for internal workspaces only. +- Use test billing provider mode. +- Simulate webhooks. +- Validate event history. + +### Step 2: Limited Beta + +- Enable for selected B2B customers. +- Monitor payment failures. +- Monitor state transitions. +- Review support tickets. + +### Step 3: Full Launch + +- Enable for all new B2B customers. +- Keep legacy subscriptions on old system temporarily. +- Run reconciliation daily. + +### Step 4: Migration + +- Migrate old subscriptions into new status model. +- Generate synthetic migration events. +- Validate entitlements. +- Confirm invoice state. +- Freeze legacy billing writes. + +--- + +## 24. Migration Plan + +For each existing subscription: + +1. Identify current provider status. +2. Map provider status to internal status. +3. Create internal subscription record. +4. Create current entitlement record. +5. Import current invoice state. +6. Create `subscription.migrated` event. +7. Run reconciliation check. +8. Validate workspace access. + +Example mapping: + +| Provider State | Internal Status | +|---|---| +| `trialing` | `trialing` | +| `active` | `active` | +| `incomplete` | `payment_pending` | +| `past_due` | `past_due` | +| `unpaid` | `suspended` | +| `canceled` | `canceled` | + +--- + +## 25. Risks + +| Risk | Mitigation | +|---|---| +| Duplicate webhooks | Idempotency keys and event deduplication | +| Out-of-order webhooks | Compare timestamps and current state | +| Incorrect access removal | Entitlement layer and audit logs | +| Customers canceled too early | Grace period and admin override | +| Trial abuse | Company-level trial eligibility checks | +| Billing provider mismatch | Daily reconciliation | +| Admin misuse | Audit logs and approval rules | +| Status sprawl | Strict status model | + +--- + +## 26. Final Default Policy + +Use this as the baseline B2B policy: + +```text +Trial lasts 14 days. +Payment method is required before trial starts. +Trial converts automatically if payment succeeds. +If payment fails, subscription becomes payment_pending. +Payment pending lasts 7 days with full access. +After 7 unpaid days, subscription becomes past_due with limited access. +After 14 unpaid days, subscription becomes suspended. +After 30 unpaid days, subscription is canceled. +Customer cancellation takes effect at period end. +Admin cancellation can be immediate. +Reactivation requires successful payment. +Chargebacks suspend access immediately. +Access is controlled through entitlements. +All transitions are event-driven and audited. +``` + +--- + +## 27. Definition of Done + +The B2B subscription system is complete when: + +- All subscription statuses are implemented. +- All transition rules are enforced. +- Billing provider webhooks are idempotent. +- Failed payments enter recovery flow. +- Trial conversion works automatically. +- Entitlements control product access. +- Admins can override safely. +- Every lifecycle change has an audit event. +- Reconciliation detects billing drift. +- Monitoring and alerts are live. +- End-to-end tests cover the full lifecycle. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 6a82a72..0107fdd 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -2,8 +2,15 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiUrl = new URL(apiOrigin) +// In Docker dev the admin app runs on port 3002 while the marketplace proxy +// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits +// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from +// port 3002, bypassing the proxy (which can't upgrade WebSocket connections). +const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined + const nextConfig = { basePath: '/admin', + ...(assetPrefix ? { assetPrefix } : {}), images: { remotePatterns: [ { diff --git a/apps/admin/src/app/dashboard/audit-logs/page.tsx b/apps/admin/src/app/dashboard/audit-logs/page.tsx index b2c8311..e163d25 100644 --- a/apps/admin/src/app/dashboard/audit-logs/page.tsx +++ b/apps/admin/src/app/dashboard/audit-logs/page.tsx @@ -34,7 +34,7 @@ export default function AdminAuditLogsPage() { cache: 'no-store', }) .then((r) => r.json()) - .then((json) => setLogs(json.data ?? [])) + .then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? []))) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) }, []) diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx index c9ccfc8..7aec60f 100644 --- a/apps/admin/src/app/dashboard/billing/page.tsx +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -57,6 +57,10 @@ const SUB_STATUS_COLORS: Record = { ACTIVE: 'text-emerald-400 bg-emerald-900/30', TRIALING: 'text-sky-400 bg-sky-900/30', PAST_DUE: 'text-orange-400 bg-orange-900/30', + PAYMENT_PENDING: 'text-yellow-400 bg-yellow-900/30', + SUSPENDED: 'text-red-400 bg-red-900/30', + EXPIRED: 'text-zinc-400 bg-zinc-800', + PAUSED: 'text-purple-400 bg-purple-900/30', CANCELLED: 'text-zinc-400 bg-zinc-800', UNPAID: 'text-red-400 bg-red-900/30', } @@ -75,7 +79,7 @@ const PLAN_COLORS: Record = { } function fmt(amount: number, currency: string) { - return `${(amount / 100).toFixed(2)} ${currency}` + return `${amount.toFixed(2)} ${currency}` } function fmtDate(iso: string | null) { @@ -221,8 +225,8 @@ export default function AdminBillingPage() { }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') - setSubscriptions(json.data ?? []) - setStats(json.stats ?? null) + setSubscriptions(json.data?.data ?? []) + setStats(json.data?.stats ?? null) } catch (err: any) { setError(err.message) } finally { @@ -263,7 +267,7 @@ export default function AdminBillingPage() { }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`) - setInvoices(json.data ?? []) + setInvoices(json.data?.data ?? []) } catch (err: any) { setInvoicesError(err.message) } finally { @@ -290,7 +294,7 @@ export default function AdminBillingPage() {

{copy.mrr}

-

{(stats.mrr / 100).toFixed(0)}

+

{stats.mrr.toFixed(0)}

{copy.active}

diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx index abe3f11..5ec0c94 100644 --- a/apps/admin/src/app/dashboard/companies/[id]/page.tsx +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -15,6 +15,7 @@ interface CompanyDetail { subscriptionPaymentRef: string | null createdAt: string subscription: { + id: string plan: string billingPeriod: string status: string @@ -25,6 +26,10 @@ interface CompanyDetail { currentPeriodEnd: string | null cancelledAt: string | null cancelAtPeriodEnd: boolean + paymentPendingSince: string | null + pastDueSince: string | null + suspendedAt: string | null + retryCount: number } | null brand: { displayName: string @@ -78,12 +83,21 @@ interface AuditLog { adminUser: { email: string } | null } +interface SubEvent { + id: string + eventType: string + source: string + payload: Record + occurredAt: string +} + interface FormState { company: { name: string slug: string email: string phone: string + status: string subscriptionPaymentRef: string } subscription: { @@ -168,6 +182,7 @@ function createFormState(company: CompanyDetail): FormState { slug: company.slug ?? '', email: company.email ?? '', phone: company.phone ?? '', + status: company.status ?? 'TRIALING', subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', }, subscription: { @@ -227,6 +242,10 @@ export default function AdminCompanyDetailPage() { const [company, setCompany] = useState(null) const [form, setForm] = useState(null) const [auditLogs, setAuditLogs] = useState([]) + const [subEvents, setSubEvents] = useState([]) + const [subActioning, setSubActioning] = useState(null) + const [subOverrideReason, setSubOverrideReason] = useState('') + const [showSubActions, setShowSubActions] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [actioning, setActioning] = useState(false) @@ -246,7 +265,14 @@ export default function AdminCompanyDetailPage() { if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') setCompany(cJson.data) setForm(createFormState(cJson.data)) - setAuditLogs(aJson.data ?? []) + setAuditLogs(aJson.data?.data ?? []) + + const subId = cJson.data?.subscription?.id + if (subId) { + const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' }) + const eJson = await eRes.json() + setSubEvents(Array.isArray(eJson.data) ? eJson.data : []) + } setError(null) } catch (err: any) { setError(err.message) @@ -341,6 +367,29 @@ export default function AdminCompanyDetailPage() { } } + async function doSubAction(action: string) { + if (!company?.subscription?.id) return + if (!subOverrideReason.trim()) { setError('A reason is required for subscription overrides'); return } + setSubActioning(action) + setError(null) + try { + const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ reason: subOverrideReason }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Action failed') + setSubOverrideReason('') + setShowSubActions(false) + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setSubActioning(null) + } + } + async function changeStatus(status: string) { setActioning(true) setError(null) @@ -445,7 +494,11 @@ export default function AdminCompanyDetailPage() { Phone updateSection('company', { phone: e.target.value })} /> - -
+
+ + + + +
+

Retry count: {company.subscription.retryCount ?? 0} / 5

+
+ )} + + )} - {auditLogs.length > 0 && ( -
-
-

Recent audit events

-
-
- {auditLogs.map((log) => ( -
-
- {log.action} - {log.resource} - {log.adminUser?.email ? {log.adminUser.email} : null} +
+ {subEvents.length > 0 && ( +
+
+

Subscription timeline

+
+
+ {subEvents.map((ev) => ( +
+
+ {ev.eventType} + {new Date(ev.occurredAt).toLocaleString()} +
+

source: {ev.source}

- {new Date(log.createdAt).toLocaleString()} -
- ))} + ))} +
-
- )} + )} + + {auditLogs.length > 0 && ( +
+
+

Recent audit events

+
+
+ {auditLogs.map((log) => ( +
+
+ {log.action} + {log.resource} + {log.adminUser?.email ? {log.adminUser.email} : null} +
+ {new Date(log.createdAt).toLocaleString()} +
+ ))} +
+
+ )} +
) diff --git a/apps/admin/src/app/dashboard/companies/page.tsx b/apps/admin/src/app/dashboard/companies/page.tsx index ceca6a2..b62250b 100644 --- a/apps/admin/src/app/dashboard/companies/page.tsx +++ b/apps/admin/src/app/dashboard/companies/page.tsx @@ -88,8 +88,9 @@ export default function AdminCompaniesPage() { }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch') - setCompanies(json.data ?? []) - setFiltered(json.data ?? []) + const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? []) + setCompanies(list) + setFiltered(list) } catch (err: any) { setError(err.message) } finally { diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index ebe4a31..c6aa2f5 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -27,6 +27,7 @@ const navLinks = [ { href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, { href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' }, + { href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, ] export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/admin/src/app/dashboard/pricing/page.tsx b/apps/admin/src/app/dashboard/pricing/page.tsx new file mode 100644 index 0000000..457ac34 --- /dev/null +++ b/apps/admin/src/app/dashboard/pricing/page.tsx @@ -0,0 +1,224 @@ +'use client' + +import { useEffect, useState } from 'react' +import { ADMIN_API_BASE } from '@/lib/api' + +interface PricingConfig { + id: string + plan: string + billingPeriod: string + amount: number + updatedAt: string + updatedBy: string | null +} + +type Draft = Record + +const PLANS = ['STARTER', 'GROWTH', 'PRO'] +const PERIODS = ['MONTHLY', 'ANNUAL'] + +const PLAN_COLORS: Record = { + STARTER: 'text-zinc-400', + GROWTH: 'text-sky-400', + PRO: 'text-violet-400', +} + +function key(plan: string, period: string) { + return `${plan}:${period}` +} + +function fmt(amount: number) { + return (amount / 100).toFixed(2) +} + +function parseCentimes(value: string): number | null { + const n = parseFloat(value) + if (isNaN(n) || n < 0) return null + return Math.round(n * 100) +} + +export default function PricingPage() { + const [configs, setConfigs] = useState([]) + const [draft, setDraft] = useState({}) + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + useEffect(() => { + const token = localStorage.getItem('admin_token') + fetch(`${ADMIN_API_BASE}/admin/pricing`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }) + .then((r) => r.json()) + .then((json) => { + if (!json?.data) throw new Error(json?.message ?? 'Failed to load') + const list: PricingConfig[] = json.data + setConfigs(list) + const initial: Draft = {} + for (const c of list) { + initial[key(c.plan, c.billingPeriod)] = fmt(c.amount) + } + setDraft(initial) + setStatus('ready') + }) + .catch((err) => { + setError(err.message) + setStatus('error') + }) + }, []) + + async function handleSave() { + setError('') + setSuccessMsg('') + const entries: { plan: string; billingPeriod: string; amount: number }[] = [] + for (const plan of PLANS) { + for (const period of PERIODS) { + const k = key(plan, period) + const amount = parseCentimes(draft[k] ?? '') + if (amount === null) { + setError(`Invalid value for ${plan} ${period}`) + return + } + entries.push({ plan, billingPeriod: period, amount }) + } + } + setSaving(true) + try { + const token = localStorage.getItem('admin_token') + const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ entries }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Save failed') + const updated: PricingConfig[] = json.data + setConfigs(updated) + const refreshed: Draft = {} + for (const c of updated) { + refreshed[key(c.plan, c.billingPeriod)] = fmt(c.amount) + } + setDraft(refreshed) + setSuccessMsg('Prices saved successfully.') + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + if (status === 'loading') { + return ( +
+
+
+ ) + } + + if (status === 'error') { + return ( +
+
{error}
+
+ ) + } + + const lastUpdated = configs + .filter((c) => c.updatedAt) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0] + + return ( +
+
+
+

Subscription Pricing

+

+ Manage plan prices in MAD (values are entered in MAD, e.g. 29.90). +

+ {lastUpdated && ( +

+ Last updated {new Date(lastUpdated.updatedAt).toLocaleString()} + {lastUpdated.updatedBy ? ` by ${lastUpdated.updatedBy}` : ''} +

+ )} +
+ +
+ + {error && ( +
{error}
+ )} + {successMsg && ( +
{successMsg}
+ )} + +
+ + + + + {PERIODS.map((period) => ( + + ))} + + + + {PLANS.map((plan, i) => ( + + + {PERIODS.map((period) => { + const k = key(plan, period) + const config = configs.find((c) => c.plan === plan && c.billingPeriod === period) + return ( + + ) + })} + + ))} + +
Plan + {period} +
+ {plan} + +
+ setDraft((prev) => ({ ...prev, [k]: e.target.value }))} + className="w-28 rounded-xl border border-stone-200 bg-white px-3 py-2 text-right text-sm font-medium text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white" + /> + MAD +
+ {config && ( +

+ Current: {fmt(config.amount)} MAD +

+ )} +
+
+ +
+

Note

+

Prices are stored in centimes (1 MAD = 100 centimes) internally. Enter values in MAD (e.g. 29.90 for 2990 centimes). Changes take effect immediately for new checkout sessions.

+
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/renters/page.tsx b/apps/admin/src/app/dashboard/renters/page.tsx index 37c3684..2590ff1 100644 --- a/apps/admin/src/app/dashboard/renters/page.tsx +++ b/apps/admin/src/app/dashboard/renters/page.tsx @@ -80,8 +80,9 @@ export default function AdminRentersPage() { }) const json = await res.json() if (!res.ok) throw new Error(json?.message ?? 'Failed') - setRenters(json.data ?? []) - setFiltered(json.data ?? []) + const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? []) + setRenters(list) + setFiltered(list) } catch (err: any) { setError(err.message) } finally { diff --git a/apps/admin/src/app/dashboard/site-config/page.tsx b/apps/admin/src/app/dashboard/site-config/page.tsx index 1bef917..d61ba16 100644 --- a/apps/admin/src/app/dashboard/site-config/page.tsx +++ b/apps/admin/src/app/dashboard/site-config/page.tsx @@ -198,8 +198,8 @@ export default function AdminSiteConfigPage() { const companiesJson = await companiesRes.json() if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies') - setCompanies(companiesJson.data ?? []) - setFiltered(companiesJson.data ?? []) + setCompanies(companiesJson.data?.data ?? []) + setFiltered(companiesJson.data?.data ?? []) const homepageJson = await homepageRes.json() if (homepageRes.ok && homepageJson?.data) { diff --git a/apps/admin/src/components/I18nProvider.tsx b/apps/admin/src/components/I18nProvider.tsx index 40ce829..9fa627e 100644 --- a/apps/admin/src/components/I18nProvider.tsx +++ b/apps/admin/src/components/I18nProvider.tsx @@ -28,6 +28,7 @@ const dictionaries: Record = { auditLogs: 'Audit Logs', adminUsers: 'Admin Users', billing: 'Billing', + pricing: 'Pricing', }, logout: 'Logout', language: 'Language', @@ -48,6 +49,7 @@ const dictionaries: Record = { auditLogs: "Journaux d'audit", adminUsers: 'Utilisateurs admin', billing: 'Facturation', + pricing: 'Tarification', }, logout: 'Déconnexion', language: 'Langue', @@ -68,6 +70,7 @@ const dictionaries: Record = { auditLogs: 'سجلات التدقيق', adminUsers: 'مستخدمو الإدارة', billing: 'الفوترة', + pricing: 'الأسعار', }, logout: 'تسجيل الخروج', language: 'اللغة', diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5626911..5a54379 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,13 @@ import { redis } from './lib/redis' import { prisma } from './lib/prisma' import { assertStorageConfiguration } from './lib/storage' import { createApp, corsOrigins } from './app' +import { + runTrialExpirationJob, + runPaymentPendingTimeoutJob, + runPastDueTimeoutJob, + runSuspensionTimeoutJob, + runPeriodEndCancellationJob, +} from './modules/subscriptions/subscription.service' const app = createApp() const server = http.createServer(app) @@ -74,6 +81,32 @@ cron.schedule('0 8 * * *', async () => { } }) +// Hourly: expire trials that ended without payment +cron.schedule('0 * * * *', async () => { + const n = await runTrialExpirationJob() + if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`) +}) + +// Hourly: payment_pending → past_due after 7 days +cron.schedule('15 * * * *', async () => { + const n = await runPaymentPendingTimeoutJob() + if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`) +}) + +// Hourly: past_due → suspended after 7 days +cron.schedule('30 * * * *', async () => { + const n = await runPastDueTimeoutJob() + if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`) +}) + +// Daily: suspended → cancelled after 16 days; and period-end cancellations +cron.schedule('0 1 * * *', async () => { + const nSuspend = await runSuspensionTimeoutJob() + const nPeriod = await runPeriodEndCancellationJob() + if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`) + if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`) +}) + // Daily: send trial-ending reminders (3 days before trial end) cron.schedule('0 9 * * *', async () => { const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index af1cd79..db0600f 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -405,3 +405,15 @@ export function getInvoicePdfRecord(invoiceId: string) { }, }) } + +export function listPricingConfigs() { + return prisma.pricingConfig.findMany({ orderBy: [{ plan: 'asc' }, { billingPeriod: 'asc' }] }) +} + +export function upsertPricingConfig(plan: string, billingPeriod: string, amount: number, updatedBy?: string) { + return prisma.pricingConfig.upsert({ + where: { plan_billingPeriod: { plan, billingPeriod } }, + update: { amount, updatedBy }, + create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy }, + }) +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index c05ba07..4dc35b7 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -3,6 +3,7 @@ import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdmi import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' import * as service from './admin.service' +import * as subService from '../subscriptions/subscription.service' import { presentAdminUser } from './admin.presenter' import { loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, @@ -10,7 +11,18 @@ import { invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema, homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema, + pricingUpdateSchema, } from './admin.schemas' +import { z } from 'zod' + +const adminSubOverrideSchema = z.object({ + reason: z.string().min(1).max(500), +}) +const adminExtendTrialSchema = z.object({ + extraDays: z.number().int().positive(), + reason: z.string().min(1).max(500), +}) +const subIdParamSchema = z.object({ subscriptionId: z.string() }) const router = Router() @@ -208,6 +220,70 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol } catch (err) { next(err) } }) +// ─── Pricing config ──────────────────────────────────────────── + +router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + ok(res, await service.getPricingConfigs()) + } catch (err) { next(err) } +}) + +router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => { + try { + const { entries } = parseBody(pricingUpdateSchema, req) + ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip)) + } catch (err) { next(err) } +}) + +// ─── Subscription admin overrides ───────────────────────────── + +router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + ok(res, await subService.getEventsBySubscriptionId(subscriptionId)) + } catch (err) { next(err) } +}) + +router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + const { extraDays, reason } = parseBody(adminExtendTrialSchema, req) + ok(res, await subService.adminExtendTrial(subscriptionId, extraDays, req.admin.id, reason)) + } catch (err) { next(err) } +}) + +router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + const { reason } = parseBody(adminSubOverrideSchema, req) + ok(res, await subService.adminExtendGracePeriod(subscriptionId, 7, req.admin.id, reason)) + } catch (err) { next(err) } +}) + +router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + const { reason } = parseBody(adminSubOverrideSchema, req) + ok(res, await subService.adminSuspendSubscription(subscriptionId, req.admin.id, reason)) + } catch (err) { next(err) } +}) + +router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + const { reason } = parseBody(adminSubOverrideSchema, req) + ok(res, await subService.adminReactivateSubscription(subscriptionId, req.admin.id, reason)) + } catch (err) { next(err) } +}) + +router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { subscriptionId } = parseParams(subIdParamSchema, req) + const { reason } = parseBody(adminSubOverrideSchema, req) + ok(res, await subService.adminCancelSubscription(subscriptionId, req.admin.id, reason)) + } catch (err) { next(err) } +}) + // ─── Site config ─────────────────────────────────────────────── router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => { diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 4eef9d5..8b92d8c 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -99,7 +99,7 @@ export const adminCompanyUpdateSchema = z.object({ plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(), status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(), - currency: z.enum(['MAD', 'USD', 'EUR']).optional(), + currency: z.literal('MAD').optional(), trialStartAt: nullableDate, trialEndAt: nullableDate, currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate, cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(), @@ -110,7 +110,7 @@ export const adminCompanyUpdateSchema = z.object({ publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString, publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl, whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(), - defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), + defaultCurrency: z.literal('MAD').optional(), isListedOnMarketplace: z.boolean().optional(), homePageConfig: z.any().optional(), menuConfig: z.any().optional(), @@ -125,7 +125,7 @@ export const adminCompanyUpdateSchema = z.object({ accountingSettings: z.object({ reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), fiscalYearStart: z.number().int().min(1).max(12).optional(), - currency: z.enum(['MAD', 'USD', 'EUR']).optional(), + currency: z.literal('MAD').optional(), accountantEmail: nullableEmail, accountantName: nullableString, autoSendReport: z.boolean().optional(), reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), @@ -174,3 +174,11 @@ export const invoiceIdParamSchema = z.object({ export const homepageUpdateSchema = z.object({ homepage: marketplaceHomepageConfigSchema, }) + +export const pricingUpdateSchema = z.object({ + entries: z.array(z.object({ + plan: z.enum(['STARTER', 'GROWTH', 'PRO']), + billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), + amount: z.number().int().positive(), + })).min(1), +}) diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index f76dfd0..c28d12c 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -296,3 +296,22 @@ export async function updateMarketplaceHomepage(homepage: any, adminId: string, }) return saved } + +export function getPricingConfigs() { + return repo.listPricingConfigs() +} + +export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) { + const results = await Promise.all( + entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)), + ) + await repo.createAuditLog({ + adminUserId: adminId, + action: 'UPDATE', + resource: 'PricingConfig', + after: toAuditJson(results), + ipAddress: ip, + userAgent: undefined, + }) + return results +} diff --git a/apps/api/src/modules/auth/auth.company.repo.ts b/apps/api/src/modules/auth/auth.company.repo.ts index a00f78a..af6e2da 100644 --- a/apps/api/src/modules/auth/auth.company.repo.ts +++ b/apps/api/src/modules/auth/auth.company.repo.ts @@ -15,7 +15,7 @@ type CompanySignupInput = { managerName: string fax?: string yearsActive: string - currency: 'MAD' | 'USD' | 'EUR' + currency: 'MAD' registrationNumber: string plan: 'STARTER' | 'GROWTH' | 'PRO' billingPeriod: 'MONTHLY' | 'ANNUAL' diff --git a/apps/api/src/modules/auth/auth.company.schemas.ts b/apps/api/src/modules/auth/auth.company.schemas.ts index 9b84b4e..a042b42 100644 --- a/apps/api/src/modules/auth/auth.company.schemas.ts +++ b/apps/api/src/modules/auth/auth.company.schemas.ts @@ -18,6 +18,6 @@ export const companySignupSchema = z.object({ preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), plan: z.enum(['STARTER', 'GROWTH', 'PRO']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), - currency: z.enum(['MAD', 'USD', 'EUR']), + currency: z.literal('MAD'), paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), }) diff --git a/apps/api/src/modules/auth/auth.renter.schemas.ts b/apps/api/src/modules/auth/auth.renter.schemas.ts index bf4ac08..650064c 100644 --- a/apps/api/src/modules/auth/auth.renter.schemas.ts +++ b/apps/api/src/modules/auth/auth.renter.schemas.ts @@ -5,7 +5,7 @@ export const renterUpdateSchema = z.object({ lastName: z.string().min(1).max(100).trim().optional(), phone: z.string().max(30).trim().optional(), preferredLocale: z.enum(['en', 'fr', 'ar']).optional(), - preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), + preferredCurrency: z.literal('MAD').optional(), }) export const renterFcmTokenSchema = z.object({ diff --git a/apps/api/src/modules/companies/company.schemas.ts b/apps/api/src/modules/companies/company.schemas.ts index 5858cfd..acd4188 100644 --- a/apps/api/src/modules/companies/company.schemas.ts +++ b/apps/api/src/modules/companies/company.schemas.ts @@ -20,7 +20,7 @@ export const brandSchema = z.object({ websiteUrl: z.string().url().optional(), whatsappNumber: z.string().optional(), defaultLocale: z.string().optional(), - defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), + defaultCurrency: z.literal('MAD').optional(), amanpayMerchantId: z.string().optional(), amanpaySecretKey: z.string().optional(), paypalEmail: z.string().email().optional(), @@ -146,7 +146,7 @@ export const pricingRuleSchema = z.object({ export const accountingSettingsSchema = z.object({ reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), fiscalYearStart: z.number().int().min(1).max(12).optional(), - currency: z.enum(['MAD', 'USD', 'EUR']).optional(), + currency: z.literal('MAD').optional(), accountantEmail: z.string().email().optional(), accountantName: z.string().optional(), autoSendReport: z.boolean().optional(), diff --git a/apps/api/src/modules/payments/payment.schemas.ts b/apps/api/src/modules/payments/payment.schemas.ts index acb0fed..6fe5edf 100644 --- a/apps/api/src/modules/payments/payment.schemas.ts +++ b/apps/api/src/modules/payments/payment.schemas.ts @@ -3,14 +3,14 @@ import { z } from 'zod' export const chargeSchema = z.object({ provider: z.enum(['AMANPAY', 'PAYPAL']), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), - currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), + currency: z.literal('MAD').default('MAD'), successUrl: z.string().url(), failureUrl: z.string().url(), }) export const manualPaymentSchema = z.object({ amount: z.number().int().positive(), - currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), + currency: z.literal('MAD').default('MAD'), type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'), paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']), }) diff --git a/apps/api/src/modules/payments/payment.service.ts b/apps/api/src/modules/payments/payment.service.ts index 6a45121..e5ca330 100644 --- a/apps/api/src/modules/payments/payment.service.ts +++ b/apps/api/src/modules/payments/payment.service.ts @@ -41,7 +41,7 @@ export async function handlePaypalWebhook(event: any) { export async function initCharge(reservationId: string, companyId: string, body: { provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT' - currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string + currency: 'MAD'; successUrl: string; failureUrl: string }) { const reservation = await repo.findReservationOrThrow(reservationId, companyId) if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid') diff --git a/apps/api/src/modules/site/site.schemas.ts b/apps/api/src/modules/site/site.schemas.ts index 352c2e1..9a02ac8 100644 --- a/apps/api/src/modules/site/site.schemas.ts +++ b/apps/api/src/modules/site/site.schemas.ts @@ -44,7 +44,7 @@ export const bookSchema = z.object({ export const paySchema = z.object({ provider: z.enum(['AMANPAY', 'PAYPAL']), - currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'), + currency: z.literal('MAD').default('MAD'), successUrl: z.string().url(), failureUrl: z.string().url(), }) diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts index 46e8f74..d1de105 100644 --- a/apps/api/src/modules/site/site.service.ts +++ b/apps/api/src/modules/site/site.service.ts @@ -159,7 +159,7 @@ export async function getBooking(slug: string, reservationId: string) { } export async function initPayment(slug: string, reservationId: string, body: { - provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string + provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string }) { const company = await repo.findCompanyBySlug(slug) const reservation = await repo.findReservationForPayment(reservationId, company.id) diff --git a/apps/api/src/modules/subscriptions/subscription.entitlement.ts b/apps/api/src/modules/subscriptions/subscription.entitlement.ts new file mode 100644 index 0000000..4a253b4 --- /dev/null +++ b/apps/api/src/modules/subscriptions/subscription.entitlement.ts @@ -0,0 +1,47 @@ +import { Request, Response, NextFunction } from 'express' +import { prisma } from '../../lib/prisma' +import { getAccessLevel, hasAnyAccess } from './subscription.policy' + +export async function requireActiveSubscription(req: Request, res: Response, next: NextFunction) { + const companyId = req.companyId + if (!companyId) return res.status(401).json({ error: 'unauthenticated' }) + + const sub = await prisma.subscription.findUnique({ where: { companyId } }) + const status = sub?.status ?? 'EXPIRED' + const accessLevel = getAccessLevel(status) + + if (!hasAnyAccess(status)) { + return res.status(403).json({ + error: 'subscription_required', + message: 'Your subscription has ended. Please reactivate to continue.', + subscriptionStatus: status, + accessLevel, + }) + } + + ;(req as any).subscriptionStatus = status + ;(req as any).accessLevel = accessLevel + next() +} + +export async function requireFullAccess(req: Request, res: Response, next: NextFunction) { + const companyId = req.companyId + if (!companyId) return res.status(401).json({ error: 'unauthenticated' }) + + const sub = await prisma.subscription.findUnique({ where: { companyId } }) + const status = sub?.status ?? 'EXPIRED' + const accessLevel = getAccessLevel(status) + + if (accessLevel !== 'full') { + return res.status(403).json({ + error: 'insufficient_access', + message: 'This action requires an active subscription.', + subscriptionStatus: status, + accessLevel, + }) + } + + ;(req as any).subscriptionStatus = status + ;(req as any).accessLevel = accessLevel + next() +} diff --git a/apps/api/src/modules/subscriptions/subscription.policy.ts b/apps/api/src/modules/subscriptions/subscription.policy.ts new file mode 100644 index 0000000..06aa26d --- /dev/null +++ b/apps/api/src/modules/subscriptions/subscription.policy.ts @@ -0,0 +1,51 @@ +export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none' + +export const SUBSCRIPTION_POLICY = { + trial: { + durationDays: 14, + requiresPaymentMethod: false, // set true when payment capture is mandatory + oneTrialPerCompany: true, + }, + payment: { + paymentPendingTimeoutDays: 7, + retryScheduleDays: [0, 3, 6, 10, 14], + maxRetryAttempts: 5, + }, + pastDue: { + timeoutDays: 7, + }, + suspension: { + cancelAfterDays: 16, + }, + notifications: { + trialEndingDaysBefore: [7, 3, 1], + paymentFailureDaysAfter: [0, 3, 6], + pastDueDaysAfter: [7], + suspensionDaysAfter: [14], + cancellationDaysAfter: [30], + }, +} as const + +export const ACCESS_MAP: Record = { + TRIALING: 'full', + ACTIVE: 'full', + PAYMENT_PENDING: 'full', + UNPAID: 'full', + PAST_DUE: 'limited', + SUSPENDED: 'read_only', + CANCELLED: 'none', + EXPIRED: 'none', + PAUSED: 'read_only', +} + +export function getAccessLevel(status: string): AccessLevel { + return ACCESS_MAP[status] ?? 'none' +} + +export function hasFullAccess(status: string): boolean { + return getAccessLevel(status) === 'full' +} + +export function hasAnyAccess(status: string): boolean { + return getAccessLevel(status) !== 'none' +} diff --git a/apps/api/src/modules/subscriptions/subscription.repo.ts b/apps/api/src/modules/subscriptions/subscription.repo.ts index c4c887c..6b6a322 100644 --- a/apps/api/src/modules/subscriptions/subscription.repo.ts +++ b/apps/api/src/modules/subscriptions/subscription.repo.ts @@ -1,60 +1,336 @@ import { prisma } from '../../lib/prisma' +// ─── Subscription queries ───────────────────────────────────── + export function findByCompany(companyId: string) { return prisma.subscription.findUnique({ where: { companyId }, - include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } }, + include: { + invoices: { orderBy: { createdAt: 'desc' }, take: 12 }, + events: { orderBy: { occurredAt: 'desc' }, take: 20 }, + }, + }) +} + +export function findById(id: string) { + return prisma.subscription.findUnique({ + where: { id }, + include: { company: true }, }) } export function findInvoices(companyId: string) { - return prisma.subscriptionInvoice.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50 }) + return prisma.subscriptionInvoice.findMany({ + where: { companyId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }) } export function findInvoiceByAmanpay(transactionId: string) { - return prisma.subscriptionInvoice.findFirst({ where: { amanpayTransactionId: transactionId }, include: { subscription: true } }) + return prisma.subscriptionInvoice.findFirst({ + where: { amanpayTransactionId: transactionId }, + include: { subscription: true }, + }) } export function findInvoiceByPaypal(captureId: string) { - return prisma.subscriptionInvoice.findFirst({ where: { paypalCaptureId: captureId }, include: { subscription: true } }) + return prisma.subscriptionInvoice.findFirst({ + where: { paypalCaptureId: captureId }, + include: { subscription: true }, + }) } export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) { - return prisma.subscriptionInvoice.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId }, include: { subscription: true } }) + return prisma.subscriptionInvoice.findFirstOrThrow({ + where: { paypalCaptureId: paypalOrderId, companyId }, + include: { subscription: true }, + }) } -export function markInvoicePaid(id: string) { - return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } }) +export function findInvoiceById(id: string) { + return prisma.subscriptionInvoice.findUniqueOrThrow({ + where: { id }, + include: { subscription: true }, + }) +} + +// ─── Subscription mutations ─────────────────────────────────── + +export async function findOrCreateSubscription( + companyId: string, + plan: string, + billingPeriod: string, + currency: string, +) { + const existing = await prisma.subscription.findUnique({ where: { companyId } }) + if (existing) return existing + return prisma.subscription.create({ + data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PAYMENT_PENDING' as any }, + }) } export function activateSubscription(id: string, periodEnd: Date) { return prisma.subscription.update({ where: { id }, - data: { status: 'ACTIVE', currentPeriodStart: new Date(), currentPeriodEnd: periodEnd }, + data: { + status: 'ACTIVE', + currentPeriodStart: new Date(), + currentPeriodEnd: periodEnd, + paymentPendingSince: null, + paymentDueAt: null, + pastDueSince: null, + suspendedAt: null, + retryCount: 0, + }, }) } -export async function findOrCreateSubscription(companyId: string, plan: string, billingPeriod: string, currency: string) { - const existing = await prisma.subscription.findUnique({ where: { companyId } }) - if (existing) return existing - return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } }) +export function startTrial( + companyId: string, + plan: string, + billingPeriod: string, + currency: string, + trialEndAt: Date, +) { + return prisma.subscription.upsert({ + where: { companyId }, + update: { + plan: plan as any, + billingPeriod: billingPeriod as any, + currency, + status: 'TRIALING', + trialStartAt: new Date(), + trialEndAt, + trialUsed: true, + }, + create: { + companyId, + plan: plan as any, + billingPeriod: billingPeriod as any, + currency, + status: 'TRIALING', + trialStartAt: new Date(), + trialEndAt, + trialUsed: true, + }, + }) } -export function createInvoice(data: { - companyId: string; subscriptionId: string; amount: number; currency: string - paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null -}) { - return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } }) +export function setPaymentPending(id: string) { + const now = new Date() + const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + return prisma.subscription.update({ + where: { id }, + data: { + status: 'PAYMENT_PENDING', + paymentPendingSince: now, + paymentDueAt: dueAt, + }, + }) } -export function updateInvoicePaypal(id: string, captureId: string) { - return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } }) +export function setPastDue(id: string) { + return prisma.subscription.update({ + where: { id }, + data: { + status: 'PAST_DUE', + pastDueSince: new Date(), + }, + }) +} + +export function setSuspended(id: string) { + return prisma.subscription.update({ + where: { id }, + data: { + status: 'SUSPENDED', + suspendedAt: new Date(), + }, + }) +} + +export function setExpired(id: string) { + return prisma.subscription.update({ + where: { id }, + data: { status: 'EXPIRED', endedAt: new Date() }, + }) +} + +export function setCancelled(id: string, immediate: boolean) { + if (immediate) { + return prisma.subscription.update({ + where: { id }, + data: { status: 'CANCELLED', cancelledAt: new Date(), endedAt: new Date() }, + }) + } + return prisma.subscription.update({ + where: { id }, + data: { cancelAtPeriodEnd: true }, + }) +} + +export function setCancelAtPeriodEnd(companyId: string, value: boolean) { + return prisma.subscription.update({ + where: { companyId }, + data: { cancelAtPeriodEnd: value }, + }) +} + +export function incrementRetryCount(id: string) { + return prisma.subscription.update({ + where: { id }, + data: { retryCount: { increment: 1 } }, + }) } export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) { return prisma.subscription.update({ where: { companyId }, data }) } -export function setCancelAtPeriodEnd(companyId: string, value: boolean) { - return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } }) +// ─── Invoice mutations ──────────────────────────────────────── + +export function createInvoice(data: { + companyId: string + subscriptionId: string + amount: number + currency: string + paymentProvider: string + amanpayTransactionId?: string | null + paypalCaptureId?: string | null + dueAt?: Date | null +}) { + return prisma.subscriptionInvoice.create({ + data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any }, + }) +} + +export function markInvoicePaid(id: string) { + return prisma.subscriptionInvoice.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), failedAt: null }, + }) +} + +export function markInvoiceFailed(id: string) { + return prisma.subscriptionInvoice.update({ + where: { id }, + data: { status: 'FAILED', failedAt: new Date() }, + }) +} + +export function markInvoiceVoided(id: string) { + return prisma.subscriptionInvoice.update({ + where: { id }, + data: { status: 'VOIDED', voidedAt: new Date() }, + }) +} + +export function updateInvoicePaypal(id: string, captureId: string) { + return prisma.subscriptionInvoice.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId, failedAt: null }, + }) +} + +// ─── Payment attempts ───────────────────────────────────────── + +export function createPaymentAttempt(data: { + invoiceId: string + subscriptionId: string + providerPaymentId?: string | null + status: string + failureCode?: string | null + failureMessage?: string | null +}) { + return prisma.paymentAttempt.create({ + data: { ...data, attemptedAt: new Date() }, + }) +} + +export function getPaymentAttempts(invoiceId: string) { + return prisma.paymentAttempt.findMany({ + where: { invoiceId }, + orderBy: { attemptedAt: 'desc' }, + }) +} + +// ─── Subscription events ────────────────────────────────────── + +export function createEvent(data: { + subscriptionId: string + companyId: string + eventType: string + source?: string + payload?: object +}) { + return prisma.subscriptionEvent.create({ + data: { + ...data, + source: data.source ?? 'system', + payload: (data.payload ?? {}) as any, + occurredAt: new Date(), + }, + }) +} + +export function getEvents(subscriptionId: string) { + return prisma.subscriptionEvent.findMany({ + where: { subscriptionId }, + orderBy: { occurredAt: 'desc' }, + take: 50, + }) +} + +// ─── Scheduled job helpers ──────────────────────────────────── + +export function findPaymentPendingTimedOut(cutoffDate: Date) { + return prisma.subscription.findMany({ + where: { + status: 'PAYMENT_PENDING', + paymentPendingSince: { lte: cutoffDate }, + }, + include: { company: true }, + }) +} + +export function findPastDueTimedOut(cutoffDate: Date) { + return prisma.subscription.findMany({ + where: { + status: 'PAST_DUE', + pastDueSince: { lte: cutoffDate }, + }, + include: { company: true }, + }) +} + +export function findSuspendedTimedOut(cutoffDate: Date) { + return prisma.subscription.findMany({ + where: { + status: 'SUSPENDED', + suspendedAt: { lte: cutoffDate }, + }, + include: { company: true }, + }) +} + +export function findTrialsEndedWithoutConversion() { + return prisma.subscription.findMany({ + where: { + status: 'TRIALING', + trialEndAt: { lte: new Date() }, + }, + include: { company: { include: { employees: { where: { role: 'OWNER' }, take: 1 } } } }, + }) +} + +export function findSubscriptionsToExpireAtPeriodEnd() { + return prisma.subscription.findMany({ + where: { + status: 'ACTIVE', + cancelAtPeriodEnd: true, + currentPeriodEnd: { lte: new Date() }, + }, + include: { company: true }, + }) } diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index bef3c76..8d07fdf 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -7,16 +7,23 @@ import { ok } from '../../http/respond' import * as amanpay from '../../services/amanpayService' import * as paypal from '../../services/paypalService' import * as service from './subscription.service' -import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas' +import { + checkoutSchema, + changePlanSchema, + capturePaypalSchema, + startTrialSchema, + cancelSchema, + reactivateSchema, +} from './subscription.schemas' -const publicRouter = Router() +const publicRouter = Router() const webhookRouter = Router() -const router = Router() +const router = Router() // ─── Public ──────────────────────────────────────────────────── -publicRouter.get('/plans', (_req, res) => { - ok(res, service.getPlans()) +publicRouter.get('/plans', (_req, res, next) => { + service.getPlans().then((d) => ok(res, d)).catch(next) }) publicRouter.get('/providers', (_req, res) => { @@ -27,7 +34,7 @@ publicRouter.get('/providers', (_req, res) => { webhookRouter.post('/webhooks/amanpay', async (req, res, next) => { try { - const rawBody = JSON.stringify(req.body) + const rawBody = JSON.stringify(req.body) const signature = (req.headers['x-amanpay-signature'] as string) ?? '' if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) { return res.status(401).json({ error: 'invalid_signature' }) @@ -56,19 +63,30 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re } catch (err) { next(err) } }) -// ─── Authenticated ──────────────────────────────────────────── +// ─── Authenticated ───────────────────────────────────────────── router.use(requireCompanyAuth, requireTenant) router.get('/me', async (req, res, next) => { - try { - ok(res, await service.getSubscription(req.companyId)) - } catch (err) { next(err) } + try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) } }) router.get('/invoices', async (req, res, next) => { + try { ok(res, await service.getInvoices(req.companyId)) } catch (err) { next(err) } +}) + +router.get('/events', async (req, res, next) => { + try { ok(res, await service.getEvents(req.companyId)) } catch (err) { next(err) } +}) + +router.get('/entitlement', async (req, res, next) => { + try { ok(res, await service.getEntitlement(req.companyId)) } catch (err) { next(err) } +}) + +router.post('/trial', requireRole('OWNER'), async (req, res, next) => { try { - ok(res, await service.getInvoices(req.companyId)) + const body = parseBody(startTrialSchema, req) + ok(res, await service.startTrial(req.companyId, body.plan, body.billingPeriod, body.currency)) } catch (err) { next(err) } }) @@ -79,6 +97,13 @@ router.post('/checkout', requireRole('OWNER'), async (req, res, next) => { } catch (err) { next(err) } }) +router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => { + try { + const body = parseBody(reactivateSchema, req) + ok(res, await service.reactivate(req.companyId, body)) + } catch (err) { next(err) } +}) + router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(changePlanSchema, req) @@ -88,14 +113,13 @@ router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { router.post('/cancel', requireRole('OWNER'), async (req, res, next) => { try { - ok(res, await service.cancel(req.companyId)) + const { mode, reason } = parseBody(cancelSchema, req) + ok(res, await service.cancel(req.companyId, mode, reason)) } catch (err) { next(err) } }) router.post('/resume', requireRole('OWNER'), async (req, res, next) => { - try { - ok(res, await service.resume(req.companyId)) - } catch (err) { next(err) } + try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) } }) export default router diff --git a/apps/api/src/modules/subscriptions/subscription.schemas.ts b/apps/api/src/modules/subscriptions/subscription.schemas.ts index 2af75bb..fb1a9be 100644 --- a/apps/api/src/modules/subscriptions/subscription.schemas.ts +++ b/apps/api/src/modules/subscriptions/subscription.schemas.ts @@ -1,20 +1,44 @@ import { z } from 'zod' +const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO']) +const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL']) +const providerEnum = z.enum(['AMANPAY', 'PAYPAL']) + export const checkoutSchema = z.object({ - plan: z.enum(['STARTER', 'GROWTH', 'PRO']), - billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), - currency: z.enum(['MAD', 'USD', 'EUR']), - provider: z.enum(['AMANPAY', 'PAYPAL']), + plan: planEnum, + billingPeriod: billingPeriodEnum, + currency: z.literal('MAD'), + provider: providerEnum, successUrl: z.string().url(), failureUrl: z.string().url(), }) export const changePlanSchema = z.object({ - plan: z.enum(['STARTER', 'GROWTH', 'PRO']), - billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), - currency: z.enum(['MAD', 'USD', 'EUR']), + plan: planEnum, + billingPeriod: billingPeriodEnum, + currency: z.literal('MAD'), }) export const capturePaypalSchema = z.object({ paypalOrderId: z.string(), }) + +export const startTrialSchema = z.object({ + plan: planEnum, + billingPeriod: billingPeriodEnum, + currency: z.literal('MAD').default('MAD'), +}) + +export const cancelSchema = z.object({ + mode: z.enum(['period_end', 'immediate']).default('period_end'), + reason: z.string().max(500).optional(), +}) + +export const reactivateSchema = z.object({ + plan: planEnum, + billingPeriod: billingPeriodEnum, + currency: z.literal('MAD'), + provider: providerEnum, + successUrl: z.string().url(), + failureUrl: z.string().url(), +}) diff --git a/apps/api/src/modules/subscriptions/subscription.service.ts b/apps/api/src/modules/subscriptions/subscription.service.ts index cdd853c..49f86d6 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.ts @@ -4,6 +4,9 @@ import { ValidationError } from '../../http/errors' import * as amanpay from '../../services/amanpayService' import * as paypal from '../../services/paypalService' import * as repo from './subscription.repo' +import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy' + +// ─── Helpers ────────────────────────────────────────────────── export function addPeriod(date: Date, period: string): Date { const d = new Date(date) @@ -11,14 +14,25 @@ export function addPeriod(date: Date, period: string): Date { return d } -export function getPlans() { - return PLAN_PRICES +// ─── Plans & providers ──────────────────────────────────────── + +export async function getPlans() { + const configs = await prisma.pricingConfig.findMany() + if (configs.length === 0) return PLAN_PRICES + const result: Record>> = {} + for (const c of configs) { + if (!result[c.plan]) result[c.plan] = {} + result[c.plan]![c.billingPeriod] = { MAD: c.amount } + } + return result } export function getProviders() { return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() } } +// ─── Reads ──────────────────────────────────────────────────── + export function getSubscription(companyId: string) { return repo.findByCompany(companyId) } @@ -27,15 +41,131 @@ export function getInvoices(companyId: string) { return repo.findInvoices(companyId) } +export function getEvents(companyId: string) { + return repo.findByCompany(companyId).then((sub) => + sub ? repo.getEvents(sub.id) : [], + ) +} + +export function getEventsBySubscriptionId(subscriptionId: string) { + return repo.getEvents(subscriptionId) +} + +// ─── Entitlements ───────────────────────────────────────────── + +export async function getEntitlement(companyId: string) { + const sub = await repo.findByCompany(companyId) + const status = sub?.status ?? 'EXPIRED' + const accessLevel = getAccessLevel(status) + return { + companyId, + subscriptionId: sub?.id ?? null, + subscriptionStatus: status, + accessLevel, + plan: sub?.plan ?? null, + currentPeriodEnd: sub?.currentPeriodEnd ?? null, + } +} + +// ─── Trial management ───────────────────────────────────────── + +export async function startTrial( + companyId: string, + plan: string, + billingPeriod: string, + currency: string, +) { + if (SUBSCRIPTION_POLICY.trial.oneTrialPerCompany) { + const existing = await repo.findByCompany(companyId) + if (existing?.trialUsed) { + throw new ValidationError('This company has already used its free trial') + } + } + + const trialEndAt = new Date( + Date.now() + SUBSCRIPTION_POLICY.trial.durationDays * 24 * 60 * 60 * 1000, + ) + const sub = await repo.startTrial(companyId, plan, billingPeriod, currency, trialEndAt) + await repo.createEvent({ + subscriptionId: sub.id, + companyId, + eventType: 'trial.started', + payload: { plan, billingPeriod, trialEndAt }, + }) + return sub +} + +// ─── Payment success (shared by all providers) ─────────────── + +async function handlePaymentSuccess(subscriptionId: string, invoiceId: string) { + const sub = await repo.findById(subscriptionId) + if (!sub) return + + await repo.markInvoicePaid(invoiceId) + await repo.createPaymentAttempt({ + invoiceId, + subscriptionId, + status: 'succeeded', + }) + + const periodEnd = addPeriod(new Date(), sub.billingPeriod) + await repo.activateSubscription(subscriptionId, periodEnd) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: sub.status === 'TRIALING' ? 'trial.converted' : 'subscription.activated', + source: 'webhook', + payload: { invoiceId, periodEnd }, + }) +} + +// ─── Payment failure ────────────────────────────────────────── + +async function handlePaymentFailure( + subscriptionId: string, + invoiceId: string, + failureCode?: string, + failureMessage?: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) return + + await repo.markInvoiceFailed(invoiceId) + await repo.createPaymentAttempt({ + invoiceId, + subscriptionId, + status: 'failed', + failureCode, + failureMessage, + }) + await repo.incrementRetryCount(sub.id) + + if (sub.status !== 'PAYMENT_PENDING') { + await repo.setPaymentPending(sub.id) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'subscription.payment_pending', + source: 'webhook', + payload: { invoiceId, failureCode }, + }) + } +} + +// ─── Webhook handlers ───────────────────────────────────────── + export async function handleAmanpayWebhook(event: any) { const transactionId = event.transaction_id ?? event.id const status = event.status?.toUpperCase() + if (status === 'PAID' || status === 'SUCCEEDED') { const invoice = await repo.findInvoiceByAmanpay(transactionId) - if (invoice) { - await repo.markInvoicePaid(invoice.id) - await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod)) - } + if (!invoice || invoice.status === 'PAID') return // idempotent + await handlePaymentSuccess(invoice.subscriptionId, invoice.id) + } else if (status === 'FAILED' || status === 'DECLINED') { + const invoice = await repo.findInvoiceByAmanpay(transactionId) + if (!invoice || invoice.status === 'PAID') return + await handlePaymentFailure(invoice.subscriptionId, invoice.id, status, event.failure_reason) } } @@ -43,22 +173,28 @@ export async function handlePaypalWebhook(event: any) { if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { const captureId = event.resource?.id as string const invoice = await repo.findInvoiceByPaypal(captureId) - if (invoice) { - await repo.markInvoicePaid(invoice.id) - await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod)) - } + if (!invoice || invoice.status === 'PAID') return // idempotent + await handlePaymentSuccess(invoice.subscriptionId, invoice.id) + } else if (event.event_type === 'PAYMENT.CAPTURE.DENIED') { + const captureId = event.resource?.id as string + const invoice = await repo.findInvoiceByPaypal(captureId) + if (!invoice || invoice.status === 'PAID') return + await handlePaymentFailure(invoice.subscriptionId, invoice.id, 'capture_denied') } } +// ─── Checkout ───────────────────────────────────────────────── + export async function checkout(companyId: string, body: { plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL' - currency: 'MAD' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL' + currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL' successUrl: string; failureUrl: string }) { - const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod] - if (!prices) throw new ValidationError('Invalid plan or billing period') - const amount = (prices as any)[body.currency] - if (!amount) throw new ValidationError('Currency not supported for this plan') + 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) @@ -83,32 +219,277 @@ export async function checkout(companyId: string, body: { 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 }) + const result = await paypal.createOrder({ + amount, currency: body.currency, orderId, description, + returnUrl: body.successUrl, cancelUrl: body.failureUrl, + }) checkoutUrl = result.approveUrl paypalCaptureId = result.orderId } - const invoice = await repo.createInvoice({ companyId, subscriptionId: subscription.id, amount, currency: body.currency, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId }) + 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, + }) return { invoice, checkoutUrl } } export async function capturePaypal(companyId: string, paypalOrderId: string) { const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId) + if (invoice.status === 'PAID') return { success: true } // idempotent const capture = await paypal.captureOrder(paypalOrderId) as Record const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId await repo.updateInvoicePaypal(invoice.id, captureId) - await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod)) + const periodEnd = addPeriod(new Date(), invoice.subscription.billingPeriod) + await repo.activateSubscription(invoice.subscriptionId, periodEnd) + await repo.createEvent({ + subscriptionId: invoice.subscriptionId, + companyId, + eventType: 'subscription.activated', + source: 'user', + payload: { invoiceId: invoice.id }, + }) return { success: true } } +// ─── Plan changes ───────────────────────────────────────────── + export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) { return repo.updatePlan(companyId, data) } -export function cancel(companyId: string) { - return repo.setCancelAtPeriodEnd(companyId, true) +// ─── Cancellation ───────────────────────────────────────────── + +export async function cancel(companyId: string, mode: 'period_end' | 'immediate' = 'period_end', reason?: string) { + const sub = await repo.findByCompany(companyId) + if (!sub) throw new ValidationError('No subscription found') + + await repo.setCancelled(sub.id, mode === 'immediate') + await repo.createEvent({ + subscriptionId: sub.id, + companyId, + eventType: 'subscription.canceled', + source: 'user', + payload: { mode, reason: reason ?? null }, + }) + return { success: true } } export function resume(companyId: string) { return repo.setCancelAtPeriodEnd(companyId, false) } + +// ─── Reactivation ──────────────────────────────────────────── + +export async function reactivate(companyId: string, body: { + plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL' + currency: 'MAD'; provider: 'AMANPAY' | 'PAYPAL' + 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) +} + +// ─── Scheduled job actions ──────────────────────────────────── + +export async function runTrialExpirationJob() { + const expired = await repo.findTrialsEndedWithoutConversion() + for (const sub of expired) { + await repo.setExpired(sub.id) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'trial.expired', + payload: { trialEndAt: sub.trialEndAt }, + }) + } + return expired.length +} + +export async function runPaymentPendingTimeoutJob() { + const cutoff = new Date( + Date.now() - SUBSCRIPTION_POLICY.payment.paymentPendingTimeoutDays * 24 * 60 * 60 * 1000, + ) + const subs = await repo.findPaymentPendingTimedOut(cutoff) + for (const sub of subs) { + await repo.setPastDue(sub.id) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'subscription.past_due', + payload: { paymentPendingSince: sub.paymentPendingSince }, + }) + } + return subs.length +} + +export async function runPastDueTimeoutJob() { + const cutoff = new Date( + Date.now() - SUBSCRIPTION_POLICY.pastDue.timeoutDays * 24 * 60 * 60 * 1000, + ) + const subs = await repo.findPastDueTimedOut(cutoff) + for (const sub of subs) { + await repo.setSuspended(sub.id) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'subscription.suspended', + payload: { pastDueSince: sub.pastDueSince }, + }) + } + return subs.length +} + +export async function runSuspensionTimeoutJob() { + const cutoff = new Date( + Date.now() - SUBSCRIPTION_POLICY.suspension.cancelAfterDays * 24 * 60 * 60 * 1000, + ) + const subs = await repo.findSuspendedTimedOut(cutoff) + for (const sub of subs) { + await repo.setCancelled(sub.id, true) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'subscription.canceled', + payload: { reason: 'suspension_timeout', suspendedAt: sub.suspendedAt }, + }) + } + return subs.length +} + +export async function runPeriodEndCancellationJob() { + const subs = await repo.findSubscriptionsToExpireAtPeriodEnd() + for (const sub of subs) { + await repo.setCancelled(sub.id, true) + await repo.createEvent({ + subscriptionId: sub.id, + companyId: sub.companyId, + eventType: 'subscription.canceled', + payload: { reason: 'period_end', currentPeriodEnd: sub.currentPeriodEnd }, + }) + } + return subs.length +} + +// ─── Admin overrides ────────────────────────────────────────── + +export async function adminExtendTrial( + subscriptionId: string, + extraDays: number, + adminId: string, + reason: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) throw new ValidationError('Subscription not found') + + const currentEnd = sub.trialEndAt ?? new Date() + const newEnd = new Date(currentEnd.getTime() + extraDays * 24 * 60 * 60 * 1000) + + await prisma.subscription.update({ + where: { id: subscriptionId }, + data: { trialEndAt: newEnd }, + }) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: 'admin.override_created', + source: 'admin', + payload: { action: 'extend_trial', extraDays, newEnd, adminId, reason }, + }) + return { trialEndAt: newEnd } +} + +export async function adminExtendGracePeriod( + subscriptionId: string, + extraDays: number, + adminId: string, + reason: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) throw new ValidationError('Subscription not found') + + await repo.setPaymentPending(sub.id) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: 'admin.override_created', + source: 'admin', + payload: { action: 'extend_grace_period', extraDays, adminId, reason }, + }) + return { success: true } +} + +export async function adminCancelSubscription( + subscriptionId: string, + adminId: string, + reason: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) throw new ValidationError('Subscription not found') + + await repo.setCancelled(sub.id, true) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: 'subscription.canceled', + source: 'admin', + payload: { adminId, reason }, + }) + return { success: true } +} + +export async function adminReactivateSubscription( + subscriptionId: string, + adminId: string, + reason: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) throw new ValidationError('Subscription not found') + + const periodEnd = addPeriod(new Date(), sub.billingPeriod) + await repo.activateSubscription(subscriptionId, periodEnd) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: 'subscription.reactivated', + source: 'admin', + payload: { adminId, reason, periodEnd }, + }) + return { success: true } +} + +export async function adminSuspendSubscription( + subscriptionId: string, + adminId: string, + reason: string, +) { + const sub = await repo.findById(subscriptionId) + if (!sub) throw new ValidationError('Subscription not found') + + await repo.setSuspended(sub.id) + await repo.createEvent({ + subscriptionId, + companyId: sub.companyId, + eventType: 'subscription.suspended', + source: 'admin', + payload: { adminId, reason }, + }) + return { success: true } +} diff --git a/apps/dashboard/src/app/(dashboard)/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/billing/page.tsx index 1ed5210..1c3a058 100644 --- a/apps/dashboard/src/app/(dashboard)/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/billing/page.tsx @@ -25,7 +25,7 @@ type PaymentRow = { id: string reservationId: string amount: number - currency: 'MAD' | 'USD' | 'EUR' + currency: string status: string type: 'CHARGE' | 'DEPOSIT' paymentProvider: 'AMANPAY' | 'PAYPAL' @@ -71,7 +71,7 @@ export default function BillingPage() { const [paymentModalRow, setPaymentModalRow] = useState(null) const [paymentMethod, setPaymentMethod] = useState('CASH') const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE') - const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD') + const paymentCurrency = 'MAD' const [paymentAmount, setPaymentAmount] = useState('') const [submittingPayment, setSubmittingPayment] = useState(false) const [paymentError, setPaymentError] = useState(null) @@ -393,7 +393,6 @@ export default function BillingPage() { setPaymentModalRow(row) setPaymentMethod('CASH') setPaymentType('CHARGE') - setPaymentCurrency('MAD') setPaymentAmount(String((row.balanceDue / 100).toFixed(2))) setPaymentError(null) } @@ -614,15 +613,6 @@ export default function BillingPage() {

{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}

-
- - -
-
setPaymentAmount(event.target.value)} disabled={submittingPayment} /> diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index d5ca660..45fc6d2 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -8,14 +8,13 @@ import { useDashboardI18n } from '@/components/I18nProvider' type Plan = 'STARTER' | 'GROWTH' | 'PRO' type BillingPeriod = 'MONTHLY' | 'ANNUAL' -type Currency = 'MAD' | 'USD' | 'EUR' interface Subscription { id: string plan: Plan billingPeriod: BillingPeriod status: string - currency: Currency + currency: string trialEndAt: string | null currentPeriodEnd: string | null cancelAtPeriodEnd: boolean @@ -24,7 +23,7 @@ interface Subscription { interface Invoice { id: string amount: number - currency: Currency + currency: string status: string paymentProvider: string paidAt: string | null @@ -67,7 +66,7 @@ export default function SubscriptionPage() { const [selectedPlan, setSelectedPlan] = useState('STARTER') const [billingPeriod, setBillingPeriod] = useState('MONTHLY') - const [currency, setCurrency] = useState('MAD') + const currency = 'MAD' const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY') const [providerAvailability, setProviderAvailability] = useState({ amanpay: false, paypal: false }) const [paying, setPaying] = useState(false) @@ -245,7 +244,7 @@ export default function SubscriptionPage() { setSubscription(sub) setSelectedPlan(sub.plan) setBillingPeriod(sub.billingPeriod) - setCurrency(sub.currency as Currency) + // currency is always MAD } setInvoices(inv ?? []) }) @@ -400,21 +399,6 @@ export default function SubscriptionPage() { ))}
- {/* Currency selector */} -
- {(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => ( - - ))} -
- {/* Plan cards */}
{PLANS.map((plan) => { @@ -435,7 +419,7 @@ export default function SubscriptionPage() { {isActive && {copy.active}}

- {price ? formatCurrency(price, currency) : '—'} + {price ? formatCurrency(price, 'MAD') : '—'} /{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}

    @@ -482,7 +466,7 @@ export default function SubscriptionPage() {

    {copy.total}

    - {planPrice ? formatCurrency(planPrice, currency) : '—'} + {planPrice ? formatCurrency(planPrice, 'MAD') : '—'} /{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}

    @@ -530,7 +514,7 @@ export default function SubscriptionPage() { {inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'} - {formatCurrency(inv.amount, inv.currency)} + {formatCurrency(inv.amount, 'MAD')} ))} diff --git a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx index fe9864c..adffd88 100644 --- a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx +++ b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx @@ -58,12 +58,20 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde setLoading(true) setError(null) try { - const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }) - if (!res.ok) throw new Error() + // Try employee first, then admin — mirrors the sign-in page which handles both + const [empRes, adminRes] = await Promise.all([ + fetch(`${API_BASE}/auth/employee/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }), + fetch(`${API_BASE}/admin/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }), + ]) + if (!empRes.ok && !adminRes.ok) throw new Error() setSent(true) } catch { setError(dict.error) diff --git a/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx b/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx index 3c9659a..6f68c58 100644 --- a/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx @@ -29,7 +29,7 @@ type SignupForm = { yearsActive: string plan: 'STARTER' | 'GROWTH' | 'PRO' billingPeriod: 'MONTHLY' | 'ANNUAL' - currency: 'MAD' | 'USD' | 'EUR' + currency: 'MAD' paymentProvider: 'AMANPAY' | 'PAYPAL' } @@ -557,9 +557,8 @@ export default function SignUpPage() { ))} -
    +
    setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} /> - setForm((current) => ({ ...current, currency: value as SignupForm['currency'] }))} /> setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
    setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} /> @@ -571,7 +570,7 @@ export default function SignUpPage() {

    {dict.reviewWorkspace}: {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? / {form.companyName.ar} : null}

    -

    {dict.reviewPlan}: {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {form.currency}

    +

    {dict.reviewPlan}: {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · MAD

    {dict.reviewOwnerEmail}: {form.email || '—'}

    {dict.reviewPhone}: {form.companyPhone || '—'}

    @@ -741,7 +740,6 @@ function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY' } -function normalizeCurrency(value: string | null): SignupForm['currency'] { - if (value === 'USD' || value === 'EUR' || value === 'MAD') return value +function normalizeCurrency(_value: string | null): SignupForm['currency'] { return 'MAD' } diff --git a/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx b/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx index 299bcb3..53e18ec 100644 --- a/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx +++ b/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx @@ -5,29 +5,12 @@ import Link from 'next/link' import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { resolveBrowserAppUrl } from '@/lib/appUrls' -type Currency = 'MAD' | 'USD' | 'EUR' type Billing = 'monthly' | 'annual' -const SYMBOL: Record = { MAD: 'MAD', USD: '$', EUR: '€' } - -const LANGUAGE_CURRENCY: Record = { ar: 'MAD', fr: 'EUR', en: 'USD' } - -const PRICES: Record> = { - STARTER: { - MAD: { monthly: 299, annual: 299 }, - USD: { monthly: 29, annual: 290 }, - EUR: { monthly: 27, annual: 270 }, - }, - GROWTH: { - MAD: { monthly: 599, annual: 399 }, - USD: { monthly: 59, annual: 590 }, - EUR: { monthly: 55, annual: 550 }, - }, - PRO: { - MAD: { monthly: 999, annual: 9990 }, - USD: { monthly: 99, annual: 990 }, - EUR: { monthly: 92, annual: 920 }, - }, +const PRICES: Record = { + STARTER: { monthly: 299, annual: 2990 }, + GROWTH: { monthly: 399, annual: 3990 }, + PRO: { monthly: 499, annual: 4990 }, } const PLANS = [ @@ -110,14 +93,8 @@ const copy = { }, } as const -function fmt(value: number, currency: Currency, billing: Billing): string { - const sym = SYMBOL[currency] - const amount = billing === 'annual' ? Math.round(value / 12) : value - return currency === 'MAD' ? `${amount} ${sym}` : `${sym}${amount}` -} - -function annualSavingsPct(plan: string, currency: Currency): number { - const { monthly, annual } = PRICES[plan][currency] +function annualSavingsPct(plan: string): number { + const { monthly, annual } = PRICES[plan] const wouldPay = monthly * 12 return Math.round(((wouldPay - annual) / wouldPay) * 100) } @@ -125,58 +102,34 @@ function annualSavingsPct(plan: string, currency: Currency): number { export default function PricingClient() { const { language } = useMarketplacePreferences() const dict = copy[language] - const [currency, setCurrency] = useState(() => LANGUAGE_CURRENCY[language] ?? 'MAD') const [billing, setBilling] = useState('monthly') - useEffect(() => { - setCurrency(LANGUAGE_CURRENCY[language] ?? 'MAD') - }, [language]) const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard') - const savings = annualSavingsPct('STARTER', currency) + const savings = annualSavingsPct('STARTER') return (
    - {/* Toggles */} -
    - {/* Billing toggle */} -
    - {(['monthly', 'annual'] as Billing[]).map((b) => ( - - ))} -
    - - {/* Currency toggle */} -
    - {(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => ( - - ))} -
    + {/* Billing toggle */} +
    + {(['monthly', 'annual'] as Billing[]).map((b) => ( + + ))}
    {billing === 'annual' && ( @@ -188,11 +141,10 @@ export default function PricingClient() { {/* Plan cards */}
    {PLANS.map((plan) => { - const price = PRICES[plan.key][currency] + const price = PRICES[plan.key] const displayPrice = billing === 'annual' ? Math.round(price.annual / 12) : price.monthly - const sym = SYMBOL[currency] return (
    - {currency === 'MAD' ? displayPrice : `${sym}${displayPrice}`} + {displayPrice} - {currency === 'MAD' && ( - {sym} - )} + MAD {dict.perMonth}
    {billing === 'annual' && (

    - {dict.billedAs}{' '} - {currency === 'MAD' - ? `${price.annual} ${sym}` - : `${sym}${price.annual}`}{' '} - {dict.yearly} + {dict.billedAs} {price.annual} MAD {dict.yearly}

    )}
    @@ -254,7 +200,7 @@ export default function PricingClient() {
setProfile((current) => current ? { ...current, preferredCurrency: value } : current)} + value="MAD" + options={['MAD']} + onChange={() => {}} />
diff --git a/command_Create_admin_account.md b/command_Create_admin_account.md new file mode 100644 index 0000000..8b4537e --- /dev/null +++ b/command_Create_admin_account.md @@ -0,0 +1,62 @@ + The seed script skips creation if the email already exists. The + most flexible approach is a direct API call (if you have a token) + or a one-liner script. Here are both options: + + Option 1 — via API (requires an existing admin to be logged in + first): + + curl -s -X POST http://localhost:4000/api/v1/admin/admins \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "email": "newadmin@example.com", + "firstName": "First", + "lastName": "Last", + "role": "ADMIN", + "password": "yourpassword123" + }' + + Option 2 — directly in the database (no token needed, works any + time): + + DATABASE_URL="postgresql://postgres:password@localhost:5432/renta + ldrivego" node -e " + const { PrismaClient } = + require('./packages/database/generated'); + const bcrypt = require('./node_modules/bcryptjs'); + const p = new PrismaClient(); + const EMAIL = 'newadmin@example.com'; + const PASSWORD = 'yourpassword123'; + const FIRST = 'First'; + const LAST = 'Last'; + const ROLE = 'SUPER_ADMIN'; // SUPER_ADMIN | ADMIN | SUPPORT | + FINANCE | VIEWER + bcrypt.hash(PASSWORD, 12).then(hash => + p.adminUser.create({ data: { email: EMAIL, firstName: FIRST, + lastName: LAST, passwordHash: hash, role: ROLE, isActive: true } + }) + ).then(a => { console.log('Created:', a.email, a.role); + p.\$disconnect(); }) + .catch(e => { console.error('Error:', e.message); + p.\$disconnect(); }); + " + + From inside Docker: + + docker exec rentaldrivego-dev-api-1 node -e " + const { PrismaClient } = + require('/app/packages/database/generated'); + const bcrypt = require('/app/node_modules/bcryptjs'); + const p = new PrismaClient(); + bcrypt.hash('yourpassword123', 12).then(hash => + p.adminUser.create({ data: { email: 'newadmin@example.com', + firstName: 'First', lastName: 'Last', passwordHash: hash, role: + 'SUPER_ADMIN', isActive: true } }) + ).then(a => { console.log('Created:', a.email, a.role); + p.\$disconnect(); }) + .catch(e => { console.error('Error:', e.message); + p.\$disconnect(); }); + " + + Replace email, password, firstName, lastName, and role as needed. + Available roles: SUPER_ADMIN, ADMIN, SUPPORT, FINANCE, VIEWER. \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a302f28..7edadae 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -156,6 +156,8 @@ services: - api env_file: - .env.docker.dev + environment: + ADMIN_ASSET_PREFIX: "http://localhost:3002" command: [ "sh", diff --git a/packages/database/prisma/migrations/20260525000000_add_pricing_config/migration.sql b/packages/database/prisma/migrations/20260525000000_add_pricing_config/migration.sql new file mode 100644 index 0000000..3036dcc --- /dev/null +++ b/packages/database/prisma/migrations/20260525000000_add_pricing_config/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE "pricing_configs" ( + "id" TEXT NOT NULL, + "plan" TEXT NOT NULL, + "billingPeriod" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT, + + CONSTRAINT "pricing_configs_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "pricing_configs_plan_billingPeriod_key" ON "pricing_configs"("plan", "billingPeriod"); + +-- Seed default prices (amounts in centimes MAD) +INSERT INTO "pricing_configs" ("id", "plan", "billingPeriod", "amount", "updatedAt") VALUES + ('prc_starter_monthly', 'STARTER', 'MONTHLY', 2990, NOW()), + ('prc_starter_annual', 'STARTER', 'ANNUAL', 29900, NOW()), + ('prc_growth_monthly', 'GROWTH', 'MONTHLY', 3990, NOW()), + ('prc_growth_annual', 'GROWTH', 'ANNUAL', 39900, NOW()), + ('prc_pro_monthly', 'PRO', 'MONTHLY', 99900, NOW()), + ('prc_pro_annual', 'PRO', 'ANNUAL', 999000, NOW()); diff --git a/packages/database/prisma/migrations/20260525130000_b2b_subscription_lifecycle/migration.sql b/packages/database/prisma/migrations/20260525130000_b2b_subscription_lifecycle/migration.sql new file mode 100644 index 0000000..57452f8 --- /dev/null +++ b/packages/database/prisma/migrations/20260525130000_b2b_subscription_lifecycle/migration.sql @@ -0,0 +1,66 @@ +-- Add new SubscriptionStatus enum values +ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAYMENT_PENDING'; +ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED'; +ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'EXPIRED'; +ALTER TYPE "SubscriptionStatus" ADD VALUE IF NOT EXISTS 'PAUSED'; + +-- Add new InvoiceStatus enum value +ALTER TYPE "InvoiceStatus" ADD VALUE IF NOT EXISTS 'VOIDED'; + +-- Extend subscriptions table (camelCase to match Prisma default) +ALTER TABLE "subscriptions" + ADD COLUMN IF NOT EXISTS "trialUsed" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "paymentPendingSince" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "paymentDueAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "pastDueSince" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "suspendedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "endedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "retryCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "maxRetryCount" INTEGER NOT NULL DEFAULT 5; + +-- Extend subscription_invoices table +ALTER TABLE "subscription_invoices" + ADD COLUMN IF NOT EXISTS "providerInvoiceId" TEXT, + ADD COLUMN IF NOT EXISTS "dueAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "failedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "voidedAt" TIMESTAMP(3); + +-- Create subscription_events table +CREATE TABLE IF NOT EXISTS "subscription_events" ( + "id" TEXT NOT NULL, + "subscriptionId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'system', + "payload" JSONB NOT NULL DEFAULT '{}', + "occurredAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "subscription_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "subscription_events_subscriptionId_idx" ON "subscription_events"("subscriptionId"); +CREATE INDEX IF NOT EXISTS "subscription_events_companyId_idx" ON "subscription_events"("companyId"); + +ALTER TABLE "subscription_events" + ADD CONSTRAINT "subscription_events_subscriptionId_fkey" + FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create payment_attempts table +CREATE TABLE IF NOT EXISTS "payment_attempts" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "subscriptionId" TEXT NOT NULL, + "providerPaymentId" TEXT, + "status" TEXT NOT NULL, + "failureCode" TEXT, + "failureMessage" TEXT, + "attemptedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "payment_attempts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "payment_attempts_invoiceId_idx" ON "payment_attempts"("invoiceId"); + +ALTER TABLE "payment_attempts" + ADD CONSTRAINT "payment_attempts_invoiceId_fkey" + FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8b14875..45b05fd 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -35,8 +35,12 @@ enum BillingPeriod { enum SubscriptionStatus { TRIALING ACTIVE + PAYMENT_PENDING PAST_DUE + SUSPENDED CANCELLED + EXPIRED + PAUSED UNPAID } @@ -45,6 +49,7 @@ enum InvoiceStatus { PAID FAILED REFUNDED + VOIDED } enum PaymentProvider { @@ -357,20 +362,29 @@ model Company { } model Subscription { - id String @id @default(cuid()) - companyId String @unique - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - plan Plan @default(STARTER) - billingPeriod BillingPeriod @default(MONTHLY) - status SubscriptionStatus @default(TRIALING) - currency String @default("MAD") - trialStartAt DateTime? - trialEndAt DateTime? - currentPeriodStart DateTime? - currentPeriodEnd DateTime? - cancelledAt DateTime? - cancelAtPeriodEnd Boolean @default(false) - invoices SubscriptionInvoice[] + id String @id @default(cuid()) + companyId String @unique + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + plan Plan @default(STARTER) + billingPeriod BillingPeriod @default(MONTHLY) + status SubscriptionStatus @default(TRIALING) + currency String @default("MAD") + trialStartAt DateTime? + trialEndAt DateTime? + trialUsed Boolean @default(false) + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + paymentPendingSince DateTime? + paymentDueAt DateTime? + pastDueSince DateTime? + suspendedAt DateTime? + endedAt DateTime? + cancelledAt DateTime? + cancelAtPeriodEnd Boolean @default(false) + retryCount Int @default(0) + maxRetryCount Int @default(5) + invoices SubscriptionInvoice[] + events SubscriptionEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -378,19 +392,40 @@ model Subscription { @@map("subscriptions") } +model SubscriptionEvent { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + companyId String + eventType String + source String @default("system") + payload Json @default("{}") + occurredAt DateTime + createdAt DateTime @default(now()) + + @@index([subscriptionId]) + @@index([companyId]) + @@map("subscription_events") +} + model SubscriptionInvoice { - id String @id @default(cuid()) + id String @id @default(cuid()) companyId String - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) subscriptionId String - subscription Subscription @relation(fields: [subscriptionId], references: [id]) + subscription Subscription @relation(fields: [subscriptionId], references: [id]) + providerInvoiceId String? amount Int - currency String @default("MAD") + currency String @default("MAD") status InvoiceStatus - amanpayTransactionId String? @unique - paypalCaptureId String? @unique - paymentProvider PaymentProvider @default(AMANPAY) + amanpayTransactionId String? @unique + paypalCaptureId String? @unique + paymentProvider PaymentProvider @default(AMANPAY) + dueAt DateTime? paidAt DateTime? + failedAt DateTime? + voidedAt DateTime? + attempts PaymentAttempt[] createdAt DateTime @default(now()) @@ -398,6 +433,22 @@ model SubscriptionInvoice { @@map("subscription_invoices") } +model PaymentAttempt { + id String @id @default(cuid()) + invoiceId String + invoice SubscriptionInvoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + subscriptionId String + providerPaymentId String? + status String + failureCode String? + failureMessage String? + attemptedAt DateTime + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@map("payment_attempts") +} + model BrandSettings { id String @id @default(cuid()) companyId String @unique @@ -1115,3 +1166,16 @@ model AuditLog { @@index([action]) @@map("audit_logs") } + +model PricingConfig { + id String @id @default(cuid()) + plan String + billingPeriod String + amount Int + + updatedAt DateTime @updatedAt + updatedBy String? + + @@unique([plan, billingPeriod]) + @@map("pricing_configs") +} diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 0b7b24d..f16e9c2 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -19,32 +19,32 @@ export interface ApiError { [key: string]: unknown } -// Plan prices in smallest currency unit +// Plan prices in smallest currency unit (MAD, in centimes) export const PLAN_PRICES: Record>> = { STARTER: { - MONTHLY: { MAD: 2990, USD: 2900, EUR: 2700 }, - ANNUAL: { MAD: 29900, USD: 29000, EUR: 27000 }, + MONTHLY: { MAD: 2990 }, + ANNUAL: { MAD: 29900 }, }, GROWTH: { - MONTHLY: { MAD: 3990, USD: 5900, EUR: 5400 }, - ANNUAL: { MAD: 39900, USD: 59000, EUR: 54000 }, + MONTHLY: { MAD: 3990 }, + ANNUAL: { MAD: 39900 }, }, PRO: { - MONTHLY: { MAD: 99900, USD: 9900, EUR: 9000 }, - ANNUAL: { MAD: 999000, USD: 99000, EUR: 90000 }, + MONTHLY: { MAD: 99900 }, + ANNUAL: { MAD: 999000 }, }, } export type Locale = 'en' | 'fr' | 'ar' -export type SupportedCurrency = 'MAD' | 'USD' | 'EUR' +export type SupportedCurrency = 'MAD' -export function formatCurrency(amount: number, currency: SupportedCurrency, locale: Locale = 'en'): string { +export function formatCurrency(amount: number, _currency: SupportedCurrency = 'MAD', locale: Locale = 'en'): string { const divisor = 100 const value = amount / divisor const localeMap: Record = { en: 'en-US', fr: 'fr-FR', ar: 'ar-MA' } return new Intl.NumberFormat(localeMap[locale], { style: 'currency', - currency, + currency: 'MAD', minimumFractionDigits: 2, }).format(value) }