fe8ffbeb9f
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
820 lines
65 KiB
Markdown
820 lines
65 KiB
Markdown
# Manual Subscription Payment Plan
|
||
|
||
**Status:** Historical design note. Stripe was later removed; subscription billing is bank transfer and check only.
|
||
|
||
## 1. Target outcome
|
||
|
||
Add a manual subscription-payment path alongside Stripe:
|
||
|
||
1. A company owner selects a plan and chooses **Bank transfer** or **Check**.
|
||
2. The server calculates the price and creates one pending subscription invoice.
|
||
3. The dashboard shows the amount, invoice number, due date, and configured payment instructions.
|
||
4. The owner pays outside the application, enters the transaction reference/check number, and uploads supporting evidence such as a transfer receipt or check copy.
|
||
5. The evidence is stored privately, scanned, and submitted to a finance review queue. Uploading evidence does not mark the invoice paid.
|
||
6. The finance admin reviews the evidence and independently verifies that the transfer settled or the check cleared.
|
||
7. The admin confirms the authoritative method and reference against the correct invoice.
|
||
8. In one database transaction, the system records the payment, accepts the evidence submission, marks the invoice paid, activates the purchased subscription period, and writes billing and audit events.
|
||
|
||
Stripe must continue to use webhook-confirmed payment. A reference entered by an admin must never be treated as independent proof that money was received.
|
||
|
||
For every renewal, the system must also run the collections schedule in section 9: targeted pre-expiration reminders, a required platform-admin call task, a 30-day active grace period, daily overdue notices, and suspension at the start of day 31 unless payment is confirmed or an authorized override is active.
|
||
|
||
## 2. Critical current-state findings
|
||
|
||
| Area | Current code | Consequence |
|
||
| --- | --- | --- |
|
||
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` hard-codes `provider = 'STRIPE'` | There is no manual choice or payment-instruction screen. |
|
||
| Subscription API contract | `api/src/modules/subscriptions/subscription.schemas.ts` accepts only `STRIPE` | Bank transfer/check requests fail validation. |
|
||
| Subscription checkout | `api/src/modules/subscriptions/subscription.service.ts` always creates a Stripe Checkout session | A non-Stripe pending invoice cannot be created. |
|
||
| Admin payment API | `api/src/modules/admin/admin.schemas.ts` already accepts an optional `providerPaymentId` | The backend has a partial manual-recording concept, but method/reference are optional and there is no idempotency contract. |
|
||
| Admin payment service | `api/src/modules/admin/admin.billing.service.ts::payInvoice` immediately writes successful payment records | It lacks required manual-method validation, reference deduplication, concurrency protection, and a cleared-funds attestation. |
|
||
| Admin UI | `admin/src/app/dashboard/billing/page.tsx` sends only an optional amount and labels it “Amount in cents” | The transaction reference is never sent even though the API can accept one. The money input is also error-prone. |
|
||
| Invoice architecture | Stripe creates `SubscriptionInvoice`; the finance console operates on `BillingInvoice` | There are two invoice lifecycles. The existing legacy sync copies data in one direction only. An admin payment can leave the customer-visible invoice pending. |
|
||
| Subscription activation | Admin payment calls `maybeRestoreSubscription`, which changes status to `ACTIVE` | It does not reliably apply the purchased plan or establish a new `currentPeriodStart`/`currentPeriodEnd`. |
|
||
| Upload infrastructure | `api/src/http/upload/index.ts` validates images only; `api/src/lib/storage.ts::uploadImage` always writes `.jpg` files | It cannot safely represent PDF receipts or preserve document type. A dedicated private-document pipeline is required. |
|
||
| Subscription scheduler | `api/src/index.ts` uses fixed server-time cron jobs and hard-coded 7-day `PAYMENT_PENDING`/`PAST_DUE` transitions | It cannot implement company-local 14-day/7-day/48-hour/24-hour milestones or the required 30-day full-access grace period. |
|
||
| Company notification targeting | `notificationService.ts` can target one employee or every company employee; no billing-contact audience exists | Using the company-wide audience would notify unrelated users. Explicit billing contacts are required. |
|
||
| Localization foundation | Dashboard/admin i18n providers and notification locale/template resolution already support Arabic, English, and French; company brand data has a default locale | The foundation can be reused, but billing-contact locale resolution, company-enabled language constraints, template parity, and audit snapshots still need to be added. |
|
||
| Platform-admin notifications | The admin notifications page is a cross-company delivery audit, not a personal admin inbox | Platform-admin milestones and call tasks need assigned admin recipients and actionable task state. |
|
||
| Timezone configuration | No company/billing timezone field is exposed in the supplied schemas | Deadline calculation cannot safely use local calendar days, especially across daylight-saving/offset changes. |
|
||
| Existing manual payments | `api/src/modules/payments/*` and `api/src/modules/billing/*` record manual rental payments | These flows concern rental reservations and mark payments successful immediately; they should not be reused for platform subscriptions. |
|
||
| Database source | The API imports `@rentaldrivego/database`, but that package/schema is not in the archive | The schema and migration changes below must be made in the full monorepo before implementation can compile. |
|
||
|
||
The main prerequisite is to stop treating `SubscriptionInvoice` and `BillingInvoice` as independent sources of truth. Adding fields to the admin form without resolving that split would create inconsistent financial state.
|
||
|
||
## 3. Product decisions for the MVP
|
||
|
||
- Keep Stripe and add two offline collection methods: `BANK_TRANSFER` and `CHECK`.
|
||
- Only company owners may create a subscription-payment request.
|
||
- Only finance admins with fresh 2FA may confirm a manual payment.
|
||
- “Confirm payment” means the admin attests that the bank transfer settled or the check cleared. Merely receiving a check is not enough.
|
||
- The customer submits the reference and evidence through the application. This replaces an unaudited email/WhatsApp handoff. The admin still confirms the authoritative reference after review.
|
||
- Require at least one clean evidence document for a manual subscription-payment submission. Recommended MVP formats: PDF, JPEG, and PNG; maximum 10 MB per file, 3 files, and 20 MB total per submission.
|
||
- Store evidence in private persistent storage, never in a public `/storage` path, database blob, source tree, or ephemeral container filesystem.
|
||
- Evidence is supporting material, not proof of settlement. A convincing-looking receipt must not activate a subscription.
|
||
- Manual subscription invoices must be paid in full for activation. Short, excess, or partial payments go to an exception workflow; they must not silently activate a plan.
|
||
- The server, never the browser, calculates the plan price.
|
||
- Store all amounts as integer minor units. The UI displays MAD values and must not ask admins to think in “cents.”
|
||
- Make `BillingInvoice` the canonical invoice. Treat `SubscriptionInvoice` as a compatibility model during migration, then retire it.
|
||
- A pending manual invoice has a method-specific due date. Generic Stripe timeout behavior must not suspend a customer while a check is still within its configured clearing window.
|
||
- Payment notices go only to explicitly configured company billing contacts and company administrators. In this codebase, “company administrator” maps to the `OWNER` role; never use the `COMPANY_EMPLOYEES` audience for collections.
|
||
- Each billing account has one assigned platform collections owner, with a monitored finance queue as fallback. Do not notify every platform admin.
|
||
- Renewal invoices must be finalized early enough for the first 14-day reminder. Recommended: generate/finalize them at least 21 days before the subscription period ends.
|
||
- Keep the subscription fully active throughout the 30-day grace period. Delinquency is a collections state, not restricted entitlement, until suspension begins on day 31.
|
||
- A created or pending payment attempt does not stop follow-up. Only canonical, successfully confirmed payment does.
|
||
- Classify required payment notices as transactional/mandatory. A company must always retain at least one valid billing recipient.
|
||
- Support exactly three communication languages for this release: Arabic (`ar`), English (`en`), and French (`fr`). Each company selects a non-empty set of enabled languages and one default language from that set.
|
||
- Resolve one language for each recipient and communication. Do not send the same notice in all enabled languages: that would create duplicate emails/in-app alerts when the company selected multiple languages.
|
||
|
||
### Communication language policy
|
||
|
||
Customer-facing dashboard copy, in-app notifications, emails, payment instructions, evidence-review messages, reminders, confirmations, dispute/extension notices, and suspension messages must use this deterministic order:
|
||
|
||
1. Use the billing contact's explicit `locale` when it is one of the company's enabled communication languages.
|
||
2. Otherwise, for a linked employee, use that employee's preferred language when it is enabled for the company.
|
||
3. Otherwise, use the company's `defaultCommunicationLocale`.
|
||
4. Use English only as a controlled emergency fallback when the configured template is unavailable. Record a localization-fallback audit event, alert operations, and do not silently mix English fragments into Arabic or French messages.
|
||
|
||
An external email-only billing contact may set a locale from the company's enabled list; otherwise the company default applies. A company-language change affects future unsent communications only. It must not resend old notices or rewrite the locale/template snapshot of communications already created.
|
||
|
||
Arabic output must set `lang="ar"` and `dir="rtl"`; English and French use `dir="ltr"`. Dates, times, number grouping, and currency presentation use the resolved locale together with the company's IANA timezone. Canonical timestamps, currency codes, and integer minor-unit amounts remain unchanged in storage.
|
||
|
||
Internal platform-admin instructions may use the assigned admin's own preferred locale so they remain operationally clear. However, each call task and customer-contact workflow must display the company's default language, the individual contact's resolved language, and a customer-facing call script/message in that language. Any email or message sent to the customer from the admin workflow must use the customer's resolved company-approved language.
|
||
|
||
## 4. State model and invariants
|
||
|
||
### Invoice lifecycle
|
||
|
||
`DRAFT` → `OPEN` → `PAID`
|
||
|
||
Additional existing states such as `PAST_DUE`, `VOID`, and `UNCOLLECTIBLE` remain valid. A fully verified payment may move `OPEN` or `PAST_DUE` to `PAID`. It must not pay a `VOID`, `REFUNDED`, or `UNCOLLECTIBLE` invoice.
|
||
|
||
### Subscription lifecycle
|
||
|
||
- For an initial purchase, creating a manual invoice sets the subscription to `PAYMENT_PENDING` without granting paid access.
|
||
- Creating a renewal invoice must not change an active subscription to `PAYMENT_PENDING` or reduce current access.
|
||
- A full admin-confirmed initial payment activates at confirmation time. `receivedAt` remains the financial settlement/clearing time.
|
||
- A confirmed renewal extends service from the immutable original expiration/current-period end, not from the early payment date. Payment during grace remains anchored to that original expiration so the grace service is not silently free.
|
||
- A renewal plan change is scheduled for the period boundary; it must not discard remaining days from the current paid period.
|
||
- Partial payment, duplicate confirmation, or failed validation must not change entitlement.
|
||
|
||
### Evidence-submission lifecycle
|
||
|
||
Payment submission: `DRAFT` → `SUBMITTED` → `UNDER_REVIEW` → `APPROVED | REJECTED`
|
||
|
||
Each document has an independent security state: `UPLOADED` → `SCANNING` → `CLEAN | QUARANTINED | SCAN_FAILED`.
|
||
|
||
- A submission cannot move to `SUBMITTED` until it has at least one `CLEAN` document.
|
||
- Customers may replace/delete documents only while the submission is `DRAFT`.
|
||
- Submitted evidence is immutable. Corrections are appended as a new submission/version; historical evidence is not overwritten.
|
||
- Admin approval is allowed only after every attached document is clean and the funds have been independently verified.
|
||
|
||
### Collections lifecycle
|
||
|
||
Keep collections state separate from subscription entitlement:
|
||
|
||
`SCHEDULED` → `PRE_DUE` → `GRACE_PERIOD` → `RESOLVED | SUSPENDED`
|
||
|
||
An authorized dispute/extension creates an overlay state, `SUSPENSION_PAUSED`, without erasing the underlying invoice or schedule.
|
||
|
||
- `PRE_DUE` begins when the renewal invoice enters the 14-day reminder window.
|
||
- At expiration, an unpaid case enters `GRACE_PERIOD`; the subscription remains `ACTIVE` with full access.
|
||
- Confirmed payment moves the case to `RESOLVED`, cancels queued follow-up, and keeps/restores the subscription `ACTIVE`.
|
||
- At the company-local time boundary that begins day 31—30 calendar days after the exact expiration time—an unpaid, unpaused case moves to `SUSPENDED` and the subscription becomes `SUSPENDED`.
|
||
- A payment attempt, uploaded receipt, submitted evidence, payment promise, or call outcome does not resolve the case.
|
||
|
||
### Non-negotiable invariants
|
||
|
||
- One authoritative invoice balance.
|
||
- One successful confirmation per external payment reference within its permitted uniqueness scope.
|
||
- One result for repeated requests with the same idempotency key.
|
||
- No invoice can be overpaid through this endpoint.
|
||
- Invoice update, payment record, subscription activation, billing event, and audit event succeed or fail together.
|
||
- No tenant can view or mutate another tenant’s invoice.
|
||
- References are normalized for comparison but preserved in their original form for finance review.
|
||
- Evidence bytes are private, content-validated, malware-scanned, encrypted at rest, and retrievable only through authorized invoice-scoped routes.
|
||
- Payment confirmation and evidence approval refer to the same invoice and submission; a document cannot be attached across tenants or reused to approve another invoice.
|
||
- Collections milestones are computed in the billing account’s valid IANA timezone, never a fixed numeric UTC offset.
|
||
- Every scheduled milestone, recipient delivery, call task, call outcome, override, confirmation, and suspension has a stable idempotency key and append-only audit evidence.
|
||
- Every customer-facing delivery stores its resolved locale, template key/version, company timezone, rendered-subject/body snapshot or immutable render inputs, and fallback status for exact audit reproduction.
|
||
- Missing/invalid timezone or missing billing recipients fails safe: alert the assigned platform collections owner and do not auto-suspend until configuration is corrected.
|
||
|
||
## 5. Proposed data changes
|
||
|
||
Make these changes in the missing `@rentaldrivego/database` package.
|
||
|
||
### `BillingInvoice`
|
||
|
||
Add or formalize:
|
||
|
||
- `collectionMethod`: `STRIPE | BANK_TRANSFER | CHECK`
|
||
- `requestedPlan`: nullable plan enum
|
||
- `requestedBillingPeriod`: nullable billing-period enum
|
||
- `manualPaymentDueAt`: nullable timestamp, or use the existing `dueAt` consistently
|
||
- `legacySubscriptionInvoiceId`: nullable unique relation during migration, instead of relying only on JSON metadata
|
||
|
||
### `BillingPaymentAttempt` or a dedicated immutable payment record
|
||
|
||
Add first-class, queryable fields rather than hiding them only in metadata:
|
||
|
||
- `channel`: `ONLINE | OFFLINE`
|
||
- `manualMethod`: nullable `BANK_TRANSFER | CHECK`
|
||
- `externalReference`: nullable original reference
|
||
- `normalizedExternalReference`: nullable normalized reference
|
||
- `receivedAt`: nullable settlement/clearance time
|
||
- `confirmedAt`: nullable admin-confirmation time
|
||
- `confirmedByAdminId`: nullable admin relation
|
||
- `idempotencyKey`: nullable UUID
|
||
- `note`: nullable, bounded text
|
||
|
||
### `ManualPaymentSubmission`
|
||
|
||
Add a review entity separate from the successful payment record:
|
||
|
||
- `id`, `invoiceId`, `billingAccountId`, and `companyId`
|
||
- `method`: `BANK_TRANSFER | CHECK`
|
||
- `submittedReference` and `normalizedSubmittedReference`
|
||
- `status`: `DRAFT | SUBMITTED | UNDER_REVIEW | APPROVED | REJECTED`
|
||
- `submittedByEmployeeId` and `submittedAt`
|
||
- `reviewedByAdminId`, `reviewedAt`, and bounded `rejectionReason`
|
||
- `idempotencyKey`
|
||
- relation to the final `BillingPaymentAttempt` after approval
|
||
|
||
The customer-supplied reference is evidence. The final payment record stores the admin-confirmed reference. If they differ, require an admin correction reason and preserve both values.
|
||
|
||
### `ManualPaymentDocument`
|
||
|
||
Store metadata and a private storage key, not file bytes:
|
||
|
||
- `id`, `submissionId`, `invoiceId`, `companyId`
|
||
- `kind`: `BANK_TRANSFER_RECEIPT | CHECK_COPY | OTHER_SUPPORTING_EVIDENCE`
|
||
- random `storageKey`; never use the original filename as a filesystem path
|
||
- sanitized `originalFilename`, detected MIME type, detected extension, and byte size
|
||
- SHA-256 digest for integrity and duplicate detection
|
||
- `scanStatus`: `UPLOADED | SCANNING | CLEAN | QUARANTINED | SCAN_FAILED`
|
||
- scanner result code without copying raw scanner output into user-visible errors
|
||
- `uploadedByEmployeeId`, `uploadedAt`, and optional draft-only deletion metadata
|
||
|
||
### `BillingAccount` and `BillingContact`
|
||
|
||
Add to the billing account:
|
||
|
||
- required IANA `timezone` such as `Africa/Casablanca`
|
||
- nullable `collectionsOwnerAdminId` identifying the internal owner
|
||
- configurable reminder send time in local time; recommended default `09:00`
|
||
- `enabledCommunicationLocales`: a non-empty set containing only `ar`, `en`, and/or `fr`
|
||
- `defaultCommunicationLocale`: one of the enabled values
|
||
|
||
Add explicit billing recipients rather than broadcasting to the company:
|
||
|
||
- `BillingContact`: `billingAccountId`, nullable linked `employeeId`, email, `locale`, `isPrimary`, `receivePaymentNotices`, and active/verified state
|
||
- linked employees receive in-app and email; an external billing email can receive email only
|
||
- active `OWNER` recipients may be designated as company administrators for payment notices
|
||
- require at least one primary active recipient; never silently expand to every employee
|
||
- validate a contact/employee locale against the company's enabled languages; missing values inherit the company default
|
||
|
||
Database constraints must require a non-empty enabled-language set, allow only `ar | en | fr`, and require the default language to be a member of the enabled set. Backfill the company language from its existing brand/default locale when that value is supported; otherwise use `en` and flag the company for owner review.
|
||
|
||
### `CollectionsCase`
|
||
|
||
Create one case per canonical renewal invoice:
|
||
|
||
- `invoiceId`, `subscriptionId`, `billingAccountId`, `companyId`
|
||
- `status`: `SCHEDULED | PRE_DUE | GRACE_PERIOD | RESOLVED | SUSPENDED`
|
||
- immutable `originalExpirationAt`
|
||
- `graceStartedAt`, `finalSuspensionAt`, `resolvedAt`, and `suspendedAt`
|
||
- `nextActionAt` for efficient worker queries
|
||
- assigned `collectionsOwnerAdminId`
|
||
- `resolutionPaymentAttemptId` when paid
|
||
- optimistic version or row-claim fields for concurrent workers
|
||
|
||
### `CollectionsCallTask`
|
||
|
||
- one unique `PRE_EXPIRY_48H_CALL` task per collections case
|
||
- assigned platform admin, due time, status `OPEN | COMPLETED | CANCELLED`
|
||
- outcome `CONTACTED | NO_ANSWER | PAYMENT_PROMISED | ISSUE_ESCALATED`
|
||
- bounded notes, `promisedPaymentAt`, `nextFollowUpAt`, completed admin/time, and cancellation reason
|
||
|
||
The task remains open until an admin records an outcome. A payment confirmed before the call is completed cancels it with a system reason rather than fabricating a human outcome. `PAYMENT_PROMISED` closes the required 48-hour call but must create a dated follow-up action; it does not resolve the collections case.
|
||
|
||
### `CollectionsOverride`
|
||
|
||
- type `PAYMENT_DISPUTE | MANUAL_EXTENSION`
|
||
- required reason, authorizing admin, created time, start/end time, and revoked time/admin
|
||
- `pauseSuspension` and separately controlled `pauseNotifications`
|
||
- extension-specific revised suspension date without changing `originalExpirationAt`
|
||
- only `FINANCE`, `ADMIN`, or `SUPER_ADMIN` with fresh 2FA may create/revoke an override
|
||
|
||
### Platform-admin notification recipients
|
||
|
||
Extend the notification audience/recipient model to target a specific `AdminUser`. The current platform notification page remains the delivery audit, but collections admins also need a personal in-app inbox/badge. Do not emulate an admin notification by sending to a company employee.
|
||
|
||
Add or reuse `AdminUser.preferredLocale` for internal admin UI/email. Store the company's default and each contact's resolved locale on the call-task snapshot so a reassignment never loses the language required for customer contact.
|
||
|
||
Recommended database constraints:
|
||
|
||
- Unique `(billingAccountId, idempotencyKey)` when `idempotencyKey` is not null.
|
||
- Unique `(billingAccountId, manualMethod, normalizedExternalReference)` for successful offline payments.
|
||
- Unique submission idempotency key within the billing account and unique document storage keys.
|
||
- Unique `CollectionsCase.invoiceId`, unique `(collectionsCaseId, taskType)`, and unique notification event `(companyId, idempotencyKey)` constraints.
|
||
- Check constraints requiring manual method, reference, confirmation time, and confirming admin when `channel = OFFLINE` and status is `SUCCEEDED`.
|
||
- Index pending invoices by `(collectionMethod, status, dueAt)` for finance queues and dunning jobs.
|
||
- Index submissions by `(status, submittedAt)` and documents by `(scanStatus, uploadedAt)` for review and quarantine workers.
|
||
- Index collections cases by `(status, nextActionAt)` and open call tasks by `(assignedAdminId, status, dueAt)`.
|
||
|
||
The application should also warn finance when the same normalized reference appears on another billing account. Do not make check numbers globally unique: different customers can legitimately use the same check number.
|
||
|
||
### Migration and backfill
|
||
|
||
1. Add nullable fields and indexes first.
|
||
2. Backfill `BillingInvoice` rows from linked `SubscriptionInvoice` rows.
|
||
3. Backfill collection method from the legacy provider.
|
||
4. Verify counts, totals, currency, paid status, and links before changing reads.
|
||
5. Switch customer and admin reads to `BillingInvoice`.
|
||
6. Keep compatibility writes to `SubscriptionInvoice` only during one release window if rollback requires them.
|
||
7. Backfill a verified IANA timezone and at least one explicit billing contact for every live billing account. Do not enable automatic suspension for incomplete accounts.
|
||
8. Create collections cases only for canonical unpaid renewal invoices; do not infer them from unconfirmed payment attempts.
|
||
9. Remove lazy, request-time legacy synchronization after the backfill is proven.
|
||
|
||
## 6. API design
|
||
|
||
### Customer: available methods and instructions
|
||
|
||
`GET /api/v1/subscriptions/payment-options`
|
||
|
||
Return only enabled methods and safe payer-facing instructions. Example:
|
||
|
||
```json
|
||
{
|
||
"methods": [
|
||
{
|
||
"method": "STRIPE",
|
||
"enabled": true
|
||
},
|
||
{
|
||
"method": "BANK_TRANSFER",
|
||
"enabled": true,
|
||
"instructions": {
|
||
"accountName": "RentalDriveGo",
|
||
"bankName": "Configured bank",
|
||
"accountReference": "Configured RIB/IBAN",
|
||
"message": "Include the invoice number in the transfer description"
|
||
}
|
||
},
|
||
{
|
||
"method": "CHECK",
|
||
"enabled": true,
|
||
"instructions": {
|
||
"payee": "RentalDriveGo",
|
||
"deliveryAddress": "Configured address",
|
||
"message": "Write the invoice number on the check"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Payment instructions should come from validated platform configuration, not JSX constants. Bank credentials used to access the bank account are secrets and must never be returned; receiving-account details intentionally shown to payers are not application credentials.
|
||
|
||
### Customer: create a manual subscription invoice
|
||
|
||
`POST /api/v1/subscriptions/manual-checkout`
|
||
|
||
```json
|
||
{
|
||
"plan": "GROWTH",
|
||
"billingPeriod": "ANNUAL",
|
||
"currency": "MAD",
|
||
"method": "BANK_TRANSFER",
|
||
"idempotencyKey": "uuid"
|
||
}
|
||
```
|
||
|
||
The response returns the canonical invoice ID/number, exact amount, due date, selected method, and instructions. Do not accept a customer-supplied amount, paid status, transaction reference, or company ID.
|
||
|
||
Rules:
|
||
|
||
- Require authenticated company owner and tenant context.
|
||
- Recalculate price from `PricingConfig`/shared plan prices.
|
||
- Reuse the result for a repeated idempotency key.
|
||
- Prevent multiple open manual invoices for the same subscription purchase. Require the owner to cancel/replace an existing open request when changing plan or method.
|
||
- Set `PAYMENT_PENDING`; do not activate access.
|
||
|
||
Keep the current Stripe checkout endpoint, but migrate it to create the same canonical `BillingInvoice` before creating the Stripe Checkout session.
|
||
|
||
### Customer: create and submit payment evidence
|
||
|
||
Use a staged workflow so upload retries cannot create duplicate payment submissions.
|
||
|
||
1. `POST /api/v1/subscriptions/invoices/:invoiceId/manual-payment-submissions`
|
||
|
||
Creates or returns a draft submission using a UUID idempotency key. Accept `method` and `submittedReference`; derive company and invoice ownership from authentication and the route.
|
||
|
||
2. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents`
|
||
|
||
Accept one multipart field named `file` plus a document `kind`. Only an owner from the same company may upload while the submission is `DRAFT`.
|
||
|
||
3. `DELETE /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
|
||
|
||
Allow only draft cleanup. After submission, documents are immutable and corrections must be appended through a replacement submission.
|
||
|
||
4. `POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/submit`
|
||
|
||
Require the invoice to remain payable, the method to match, a reference to be present, and at least one clean document. Freeze the submission and notify the finance queue.
|
||
|
||
5. `GET /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentId`
|
||
|
||
Stream a clean document only after re-validating the authenticated owner, company, invoice, and submission relationships. Never redirect to a stable public URL.
|
||
|
||
Return document metadata and scan state, never a public storage URL. Customer document reads/downloads must re-check session, tenant, invoice, and submission ownership on every request.
|
||
|
||
### Private document pipeline
|
||
|
||
Do not reuse `imageUpload` or `uploadImage` unchanged. Add a dedicated payment-evidence upload path:
|
||
|
||
- Accept only PDF, JPEG, and PNG for the MVP.
|
||
- Detect content from file signatures/structure. Do not trust `Content-Type`, filename, or extension supplied by the browser.
|
||
- Reject mismatched MIME/extension, SVG, HTML, office documents, executables, archives, password-protected/unscannable files, polyglots, oversized images, and files over the configured limits.
|
||
- Calculate SHA-256 while ingesting and assign a random storage key.
|
||
- Write first to a private quarantine location, run malware scanning, and move/mark the object usable only after a clean result. If the scanner is unavailable or times out, fail closed and keep the file unavailable.
|
||
- Use persistent private storage that works across API instances. The existing `FILE_STORAGE_ROOT` abstraction may be extended if it points to durable shared storage; object storage with private keys is preferable for multi-instance deployment.
|
||
- Never expose the existing generic `/storage` static route. Stream through protected API handlers or issue very short-lived, invoice-scoped signed downloads.
|
||
- For raw files, set a sanitized `Content-Disposition`, exact detected `Content-Type`, `X-Content-Type-Options: nosniff`, and restrictive private/no-store caching. Prefer generated image previews; do not render active or untrusted content as application HTML.
|
||
- Audit upload, submit, view/download, reject, approve, quarantine, and deletion events without logging document bytes or full financial references.
|
||
- Define retention and legal-hold behavior. After submission, deletion must follow the approved retention policy rather than a user-facing hard delete.
|
||
|
||
Add finance endpoints for the review queue and protected evidence access:
|
||
|
||
- `GET /api/v1/admin/billing/manual-payment-submissions?status=SUBMITTED`
|
||
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId`
|
||
- `GET /api/v1/admin/billing/manual-payment-submissions/:submissionId/documents/:documentId`
|
||
- `POST /api/v1/admin/billing/manual-payment-submissions/:submissionId/reject`
|
||
|
||
Rejection requires a bounded reason, preserves the evidence, notifies the owner, and leaves the invoice unpaid.
|
||
|
||
### Admin: confirm a cleared manual payment
|
||
|
||
Use an explicit endpoint instead of overloading the generic `/pay` action:
|
||
|
||
`POST /api/v1/admin/billing/invoices/:invoiceId/manual-payments`
|
||
|
||
```json
|
||
{
|
||
"submissionId": "manual_submission_id",
|
||
"method": "BANK_TRANSFER",
|
||
"externalReference": "BANK-TXN-123456",
|
||
"amount": 120000,
|
||
"receivedAt": "2026-08-09T14:30:00.000Z",
|
||
"note": "Matched to the platform bank statement",
|
||
"idempotencyKey": "uuid",
|
||
"fundsVerified": true
|
||
}
|
||
```
|
||
|
||
Validation:
|
||
|
||
- `method` must match the invoice collection method unless a finance override with a required reason is implemented.
|
||
- `submissionId` must identify a submitted, clean, same-company evidence package for this invoice.
|
||
- Reference is required, trimmed, normalized, length-bounded, and uses a conservative printable-character policy.
|
||
- `amount` must equal the full current balance for subscription invoices.
|
||
- `receivedAt` cannot be unreasonably far in the future.
|
||
- `fundsVerified` must be explicitly true; the confirmation screen must explain that the receipt/check image alone is insufficient.
|
||
- Require `FINANCE`, fresh 2FA, CSRF protection where applicable, and an idempotency key.
|
||
- Return `409` for already-paid invoices, reference conflicts, stale balances, or repeated confirmation with a different payload.
|
||
|
||
### Customer reads
|
||
|
||
Rewrite `GET /api/v1/subscriptions/invoices` to read canonical billing invoices. Include:
|
||
|
||
- invoice number and amount
|
||
- collection method
|
||
- status and due date
|
||
- paid/confirmed time
|
||
- a masked reference after confirmation
|
||
- requested plan and billing period
|
||
- evidence-submission status and safe document metadata
|
||
|
||
Do not expose admin-only notes or other companies’ references.
|
||
|
||
### Billing recipients and collections operations
|
||
|
||
Company-owner endpoints:
|
||
|
||
- `GET /api/v1/subscriptions/billing-contacts`
|
||
- `PUT /api/v1/subscriptions/billing-contacts`
|
||
- `GET /api/v1/subscriptions/communication-settings`
|
||
- `PUT /api/v1/subscriptions/communication-settings`
|
||
|
||
Require at least one active primary contact. Validate that an in-app recipient is an active employee of the authenticated company. An external email-only contact must be verified before replacing the last verified recipient.
|
||
|
||
The communication-settings write accepts only the supported base language codes and validates all contacts atomically:
|
||
|
||
```json
|
||
{
|
||
"enabledCommunicationLocales": ["ar", "fr"],
|
||
"defaultCommunicationLocale": "fr",
|
||
"contacts": [
|
||
{
|
||
"email": "billing@example.ma",
|
||
"locale": "ar",
|
||
"isPrimary": true,
|
||
"receivePaymentNotices": true
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Reject unsupported/empty locale sets, a default outside the enabled set, or a contact locale outside the enabled set. Return the effective locale for each contact. An optional authenticated preview endpoint may render subject/body samples for every enabled language without sending them, using synthetic non-sensitive values.
|
||
|
||
Platform collections endpoints:
|
||
|
||
- `GET /api/v1/admin/billing/collections?status=&assignedTo=&actionDueBefore=`
|
||
- `GET /api/v1/admin/billing/collections/:caseId`
|
||
- `PATCH /api/v1/admin/billing/collections/:caseId/assignee`
|
||
- `POST /api/v1/admin/billing/collection-tasks/:taskId/outcomes`
|
||
- `POST /api/v1/admin/billing/collections/:caseId/overrides`
|
||
- `POST /api/v1/admin/billing/collections/:caseId/overrides/:overrideId/revoke`
|
||
|
||
Call-outcome payload:
|
||
|
||
```json
|
||
{
|
||
"outcome": "PAYMENT_PROMISED",
|
||
"note": "Customer expects the transfer to settle tomorrow",
|
||
"promisedPaymentAt": "2026-09-15T12:00:00.000Z",
|
||
"nextFollowUpAt": "2026-09-15T15:00:00.000Z"
|
||
}
|
||
```
|
||
|
||
Require `promisedPaymentAt`/`nextFollowUpAt` for `PAYMENT_PROMISED` and a note for `NO_ANSWER` or `ISSUE_ESCALATED`. Recording an outcome closes the required call task but never marks the invoice paid.
|
||
|
||
Override creation requires fresh 2FA, an authorized role, a bounded reason, and a finite expiry/revised suspension date. Indefinite overrides should be prohibited unless a `SUPER_ADMIN` uses a separately audited emergency path.
|
||
|
||
## 7. Atomic confirmation algorithm
|
||
|
||
The confirmation service must run as one database transaction:
|
||
|
||
1. Resolve the idempotency key. Return the original result if the same request already succeeded.
|
||
2. Read the canonical invoice, billing account, evidence submission, and document metadata with a write lock, or use conditional updates that detect concurrent changes.
|
||
3. Validate tenant/account linkage, payable state, collection method, currency, full balance, requested subscription data, submitted evidence state, and that every attached document is clean.
|
||
4. Confirm that the submission belongs to this invoice and has not already been approved/rejected. Evidence from another invoice must never be reusable.
|
||
5. Normalize the admin-confirmed reference, compare it with the submitted reference, require a reason for a correction, and enforce the database uniqueness constraint.
|
||
6. Create an immutable successful offline payment attempt with submission link, method, original/normalized confirmed reference, settlement time, admin identity, and idempotency key.
|
||
7. Mark the evidence submission `APPROVED` and link it to that payment attempt. Evidence bytes remain immutable.
|
||
8. Conditionally update the invoice balance and status. The affected-row count must be exactly one.
|
||
9. Apply the correct period rule: initial/lapsed purchase starts at confirmation; renewal starts at the case’s immutable `originalExpirationAt` and extends from that anchor. Schedule renewal plan changes for the boundary rather than truncating the current period.
|
||
10. Clear payment-pending/past-due/suspension fields and retry counters.
|
||
11. Mark the related collections case `RESOLVED`, clear `nextActionAt`, and cancel any open call/follow-up tasks with the system reason `PAYMENT_CONFIRMED`.
|
||
12. Write `payment_evidence.approved`, `invoice.paid`, `subscription.activated`, `collections.resolved`, and admin audit records through the same transaction handle.
|
||
13. During the compatibility window, update the linked legacy `SubscriptionInvoice` in the same transaction.
|
||
14. Commit, suppress/cancel unsent collections deliveries, then send the payment receipt/resolution notification. Notification failure must not roll back a valid payment; it should enter a retry queue.
|
||
|
||
Do not keep the current pattern where the audit write happens after the billing transaction. That leaves a financial mutation without guaranteed audit evidence if the second write fails.
|
||
|
||
Stripe webhook success and admin-confirmed manual payment must call the same canonical payment-finalization routine so both stop collections identically. The notification dispatcher must re-check that the collections case is unresolved immediately before delivery; otherwise a reminder queued seconds before payment could still be sent afterward.
|
||
|
||
## 8. User experience changes
|
||
|
||
### Company dashboard
|
||
|
||
Update `dashboard/src/app/(dashboard)/subscription/page.tsx`:
|
||
|
||
- Replace the hard-coded Stripe provider with accessible Stripe, bank-transfer, and check choices returned by the API.
|
||
- Keep plan and billing-period selection.
|
||
- For Stripe, preserve the redirect flow.
|
||
- For a manual method, create the pending invoice and show a confirmation panel with invoice number, amount, due date, copyable instructions, and “Awaiting verification” status.
|
||
- Add a payment-evidence form for transaction reference/check number and one to three PDF/JPEG/PNG documents.
|
||
- Show per-file size/type validation, upload/scan progress, clean/quarantined/failed status, retry behavior, and accessible removal controls while the submission remains a draft.
|
||
- Require an explicit final **Submit for review** action. Explain that upload does not prove settlement or activate the subscription.
|
||
- After submission, show immutable evidence metadata and review state rather than editable controls.
|
||
- Display method and status in invoice history.
|
||
- During pre-due/grace states, show a persistent billing banner only to billing contacts and `OWNER` users. Include amount due, original expiration date, local grace-day count, days remaining, final suspension date, and a stable link to Stripe checkout or manual instructions/evidence submission.
|
||
- Add billing-contact management for owners and require one verified primary contact before the current contact can be removed.
|
||
- Add company communication settings where an owner enables Arabic, English, and/or French and selects exactly one default. Contact-language choices must be limited to the enabled set and show the effective inherited value.
|
||
- Disable repeated submission while a request is in flight and render idempotent retries safely.
|
||
- Add complete English, French, and Arabic copy for subscription checkout, payment instructions, evidence upload/review, collections banners, validation, and receipts. Arabic pages and email previews must be verified right-to-left.
|
||
|
||
### Admin finance console
|
||
|
||
Update `admin/src/app/dashboard/billing/page.tsx`:
|
||
|
||
- Replace the current inline “Amount in cents” control with a focused **Confirm manual payment** dialog.
|
||
- Show company, invoice number, plan, billing period, collection method, currency, total, balance due, and due date before confirmation.
|
||
- Require method, reference/check number, settlement/clearance date, optional note, and the cleared-funds checkbox.
|
||
- Show the submitted reference, scan status, document type, uploader, timestamp, and protected preview/download controls.
|
||
- Never render customer filenames or document content as HTML. Clearly distinguish customer-supplied evidence from independently verified bank/check data.
|
||
- Add **Reject evidence** with a required reason. Rejection must not change the invoice balance or subscription entitlement.
|
||
- Default the amount to the full balance and display it as MAD; keep minor-unit conversion outside free-form admin input.
|
||
- Require a final review step and handle fresh-2FA errors explicitly.
|
||
- Disable the submit button while pending and reuse the same idempotency key on transport retry.
|
||
- Show the masked reference and confirming admin in payment history. Show the full reference only in the authorized detail view if finance operations require it.
|
||
- Keep generic partial payment controls separate for non-subscription invoices if that capability is still required.
|
||
- Add an assigned collections queue ordered by overdue action, with case status, company-local deadline, amount due, contact details, latest notice, and open tasks.
|
||
- Provide personal admin in-app notifications/badge for assigned milestones rather than treating the global delivery-audit page as an inbox.
|
||
- The 48-hour call task must be visibly open until an outcome is saved. `PAYMENT_PROMISED` must show its promised date and generate the next follow-up action.
|
||
- Show the company's default language and the selected contact's effective language on each call task, with the correct localized customer script/template. Admin-generated customer messages must not allow a language outside the company's enabled set.
|
||
- Show active disputes/extensions prominently, including who authorized them, reason, expiry/revised suspension date, and whether notices are paused.
|
||
|
||
## 9. Subscription-payment follow-up, grace period, and suspension
|
||
|
||
### Roles and recipient resolution
|
||
|
||
- **Company billing contact/admin:** the customer responsible for payment. Resolve only explicit active billing contacts and designated active `OWNER` employees. Send in-app only to linked employees and email only to verified addresses.
|
||
- **Platform admin:** the assigned internal collections owner. Use a finance queue fallback if the assignee is inactive or missing; never fan out to every admin.
|
||
- Never use `COMPANY_EMPLOYEES` for payment notices. Resolve and snapshot recipients per event so the audit log shows exactly who was targeted.
|
||
- For every customer recipient, resolve the communication locale using section 3 before rendering either channel. Email and in-app deliveries for the same event/recipient must use the same resolved locale and template version.
|
||
- Internal platform-admin notices may use the admin's preferred locale, but the task must carry the company/contact language and localized customer script. Customer-facing content sent through the task always uses the customer recipient's resolved company-approved locale.
|
||
|
||
### Before expiration
|
||
|
||
| Relative time | Company billing contact/admin | Assigned platform admin | Required action |
|
||
| --- | --- | --- | --- |
|
||
| 14 days before | In-app + email | In-app | Payment reminder |
|
||
| 7 days before | In-app + email | In-app | Second reminder |
|
||
| 48 hours before | In-app + email | In-app + email | Create the unique required-call task |
|
||
| 24 hours before | In-app + email | In-app + email | Final expiration warning |
|
||
|
||
At the 48-hour threshold:
|
||
|
||
- Create exactly one `PRE_EXPIRY_48H_CALL` task and assign it to the collections owner.
|
||
- The task remains `OPEN` until the admin records `CONTACTED`, `NO_ANSWER`, `PAYMENT_PROMISED`, or `ISSUE_ESCALATED`.
|
||
- Require notes for unsuccessful/escalated outcomes. Require a promised-payment date and next follow-up time for `PAYMENT_PROMISED`.
|
||
- An overdue open call task escalates within the platform finance queue; it must not generate extra company notices.
|
||
- If payment is confirmed before the call occurs, cancel the open task as `PAYMENT_CONFIRMED` and do not require a fictitious call outcome.
|
||
|
||
Each reminder includes the invoice number, amount due, subscription expiration time in the company timezone, and a stable dashboard link that presents Stripe payment or the configured bank/check instructions. Do not place expiring signed URLs in email.
|
||
|
||
### After expiration: 30-day active grace period
|
||
|
||
If canonical payment is not confirmed at expiration:
|
||
|
||
1. Move the collections case to `GRACE_PERIOD`, but keep the subscription `ACTIVE` with full access.
|
||
2. Send exactly one in-app notification and one email per local calendar day to each current billing recipient.
|
||
3. Include amount due, original expiration date, current grace day, days remaining, final suspension time, and payment link/instructions.
|
||
4. Use the day-30 daily notice as the final warning; do not send a second duplicate “final” notice that day.
|
||
5. At the local-time boundary that begins day 31—30 calendar days after expiration in the configured IANA timezone—atomically re-check payment and overrides, then suspend if still eligible.
|
||
|
||
The suspension transaction must conditionally change the collections case and subscription once, write audit/billing events, and queue the suspension notice. If a confirmed payment wins the race, suspension affects zero rows and does nothing.
|
||
|
||
### Stop condition
|
||
|
||
Reminders stop only when the canonical invoice is paid by a successfully confirmed payment. These do **not** stop follow-up:
|
||
|
||
- created/processing/failed payment attempt
|
||
- Stripe Checkout session creation
|
||
- manual reference entry by the customer
|
||
- evidence upload or submission
|
||
- call outcome or payment promise
|
||
- email delivery/read status
|
||
|
||
On confirmation:
|
||
|
||
- resolve the collections case in the same transaction as invoice/subscription payment finalization
|
||
- clear `nextActionAt` and cancel open call/follow-up tasks
|
||
- suppress queued but unsent reminder deliveries/outbox work
|
||
- require the delivery worker to re-check unresolved state immediately before sending
|
||
- keep already-sent notification history; do not delete audit evidence
|
||
- payment during grace leaves/restores the subscription `ACTIVE` without a suspension transition
|
||
|
||
### Timezone-safe, idempotent scheduling
|
||
|
||
- Require a valid IANA timezone per billing account. Use timezone-aware calendar arithmetic; never add fixed 24-hour milliseconds for local deadlines.
|
||
- Calculate and persist `originalExpirationAt`, each milestone’s `scheduledFor`, and `finalSuspensionAt` when the collections case is created.
|
||
- Run a frequent UTC worker, recommended every 15 minutes, that claims rows where `nextActionAt <= now`. Do not create one cron schedule per timezone.
|
||
- Use database leases/conditional claims so multiple API instances cannot process the same action concurrently.
|
||
- Notification keys: `collections:{invoiceId}:{milestone}:{recipientId}`. Daily-grace keys add the company-local date. Call-task uniqueness is enforced by `(collectionsCaseId, taskType)`.
|
||
- Locale is snapshotted when the event is created and is not part of the idempotency key. A retry must reproduce the original localized notice; changing company language settings must not create a duplicate for the same milestone.
|
||
- If the worker runs repeatedly, unique constraints return the original event/task instead of sending again.
|
||
- After an outage, audit missed milestones and send only the most urgent currently applicable customer reminder per run; never dump several stale emails at once. The 48-hour call task must still be created if its threshold was crossed.
|
||
|
||
The existing notification event/outbox/delivery records should be reused for channel delivery evidence. Add `CollectionsEvent` records for scheduling decisions, call actions, overrides, resolution, and suspension.
|
||
|
||
Add dedicated localized notification types/templates rather than reusing generic payment failures: `SUBSCRIPTION_PAYMENT_DUE_14D`, `SUBSCRIPTION_PAYMENT_DUE_7D`, `SUBSCRIPTION_PAYMENT_DUE_48H`, `SUBSCRIPTION_PAYMENT_DUE_24H`, `SUBSCRIPTION_GRACE_DAILY`, `SUBSCRIPTION_GRACE_FINAL`, `COLLECTIONS_CALL_REQUIRED`, `SUBSCRIPTION_PAYMENT_CONFIRMED`, `MANUAL_PAYMENT_EVIDENCE_REJECTED`, `COLLECTIONS_OVERRIDE_CHANGED`, and `SUBSCRIPTION_SUSPENDED`. Every customer-facing template must exist in `ar`, `en`, and `fr` with an identical variable contract. Template variables must come from the canonical invoice/case, not caller-supplied display amounts or dates.
|
||
|
||
Retire the current hard-coded 7-day `runPaymentPendingTimeoutJob` and `runPastDueTimeoutJob` behavior for canonical subscription renewals before enabling this worker. Running both lifecycles would restrict or suspend accounts early.
|
||
|
||
### Disputes, extensions, and exceptions
|
||
|
||
- An authorized `PAYMENT_DISPUTE` or `MANUAL_EXTENSION` override pauses automatic suspension until its explicit expiry/revised suspension date. It must never mark the invoice paid.
|
||
- `pauseNotifications` is a separate audited choice. A dispute may replace ordinary daily demands with dispute-status communication; an extension normally reschedules reminders against the revised date.
|
||
- Expired/revoked overrides return the case to the calculated schedule. The next worker must re-check payment before any reminder or suspension.
|
||
- A `PAST_DUE` manual invoice may still be paid if it has not been voided or replaced.
|
||
- A changed plan/method must void or supersede the old open invoice; it must never leave two invoices capable of activating different plans.
|
||
- Reference collision, overpayment, underpayment, wrong currency, bounced check, or unmatched transfer goes to a finance exception queue. Do not “fix” these by editing ledger rows.
|
||
- Suspicious, infected, unscannable, unreadable, wrong-invoice, or mismatched evidence goes to quarantine/rejection and must never reach payment confirmation.
|
||
- A bounced check after activation requires compensating records, reopens collections, and re-evaluates subscription access under an authorized reversal workflow. Never delete the original payment/evidence.
|
||
|
||
## 10. Files expected to change
|
||
|
||
| Layer | Files/modules |
|
||
| --- | --- |
|
||
| Database | Missing `@rentaldrivego/database` Prisma schema and a new migration |
|
||
| Subscription contracts | `api/src/modules/subscriptions/subscription.schemas.ts` |
|
||
| Subscription routes | `api/src/modules/subscriptions/subscription.routes.ts` |
|
||
| Subscription orchestration | `api/src/modules/subscriptions/subscription.service.ts` |
|
||
| Canonical invoice repository | `api/src/modules/subscriptions/subscription.repo.ts` and/or a shared billing repository |
|
||
| Admin validation | `api/src/modules/admin/admin.schemas.ts` |
|
||
| Admin route | `api/src/modules/admin/admin.routes.ts` |
|
||
| Admin transaction logic | `api/src/modules/admin/admin.billing.service.ts` |
|
||
| Evidence validation/upload | `api/src/http/upload/index.ts` or a new `api/src/http/upload/paymentEvidence.ts` |
|
||
| Private document storage | `api/src/lib/storage.ts` plus the production persistent-storage adapter |
|
||
| Evidence service/repository | New subscription billing evidence module under `api/src/modules/subscriptions/` or a shared billing module |
|
||
| Collections policy/worker | `api/src/modules/subscriptions/subscription.policy.ts`, a new collections service/repository/worker, and `api/src/index.ts` scheduler wiring |
|
||
| Notification targeting/outbox | `api/src/services/notificationService.ts`, localization templates, and notification repository/routes |
|
||
| Locale resolution/templates | `api/src/services/notificationLocalizationService.ts`, all subscription-payment email/in-app templates, and template-variable parity checks |
|
||
| Billing contacts/timezone/language | Database schema plus company/billing-account settings schemas, services, `api/src/modules/companies/company.schemas.ts`, and owner UI |
|
||
| Admin task/override API | Admin schemas/routes/service plus collections policy authorization |
|
||
| API documentation | `api/src/swagger/openapi.ts` |
|
||
| Customer subscription UI | `dashboard/src/app/(dashboard)/subscription/page.tsx` |
|
||
| Admin billing/collections UI | `admin/src/app/dashboard/billing/page.tsx`, new collections queue/task views, and a personal admin notification inbox/badge |
|
||
| Shared types/i18n | Missing shared packages plus `dashboard/src/components/I18nProvider.tsx`, `admin/src/components/I18nProvider.tsx`, locale selectors, and Arabic RTL styles/email markup |
|
||
| Tests | Subscription, admin billing, API, integration, and end-to-end test suites |
|
||
|
||
## 11. Test plan
|
||
|
||
### Unit and schema tests
|
||
|
||
- Accept only `BANK_TRANSFER`/`CHECK` on the manual endpoint.
|
||
- Reject empty/oversized/control-character references, invalid dates, false/missing verification, floats, non-positive amounts, and wrong currency.
|
||
- Normalize case/whitespace consistently without changing the stored display value.
|
||
- Verify monthly/yearly period calculations at month-end and leap-year boundaries.
|
||
- Accept valid PDF/JPEG/PNG signatures and reject spoofed MIME types/extensions, SVG/HTML, archives, executables, malformed/polyglot files, decompression bombs, oversized images, excessive counts, and size-limit violations.
|
||
- Verify 14-day, 7-day, 48-hour, 24-hour, grace-day, and day-31 calculations in multiple IANA zones, including daylight-saving and Morocco offset changes.
|
||
- Reject invalid/fixed-offset timezone identifiers and invalid/indefinite override windows.
|
||
- Accept only `ar`, `en`, and `fr`; reject empty enabled sets, unsupported values, defaults outside the set, and contact locales outside the set.
|
||
- Verify locale precedence: explicit billing-contact locale, enabled linked-employee preference, company default, then audited emergency English fallback.
|
||
- Enforce template parity in CI: every customer-facing template key and interpolation variable must exist in all three languages, with no unresolved keys or silent mixed-language output.
|
||
|
||
### Service and database tests
|
||
|
||
- Manual checkout computes the server-side price and creates one open canonical invoice.
|
||
- Repeated checkout with the same key returns the same invoice.
|
||
- A second open invoice for the same purchase is rejected or explicitly replaces the first.
|
||
- Confirming a settled payment atomically records payment, pays invoice, activates the requested plan, and writes events/audit.
|
||
- A duplicate reference for the same account/method is rejected.
|
||
- Two concurrent confirmations cannot both change the balance or create two successful attempts.
|
||
- Underpayment, overpayment, wrong method, wrong invoice state, and stale amount do not activate the subscription.
|
||
- Admin confirmation updates the customer-visible invoice during the compatibility period.
|
||
- Past-due-but-payable succeeds; void/uncollectible/refunded fails.
|
||
- A notification failure leaves the payment committed and schedules a retry.
|
||
- Draft submission supports idempotent upload retry and submit; submitted documents cannot be replaced or deleted.
|
||
- Submission is blocked until at least one document is clean and all documents have finished scanning.
|
||
- Quarantined/scan-failed evidence cannot be viewed as clean evidence or used for approval.
|
||
- File and database failures do not leave an approved orphan document or an untracked stored object; cleanup jobs reconcile abandoned drafts/quarantine objects.
|
||
- SHA-256 digest remains stable through storage/retrieval and catches an accidental duplicate within a submission.
|
||
- Collections case creation persists the original expiration, all milestone instants, and final suspension time once.
|
||
- Each pre-due milestone reaches only explicit billing recipients and the assigned collections admin with the required channels.
|
||
- Re-running the worker or running two workers concurrently creates one notification event per recipient/milestone and one 48-hour call task.
|
||
- A company with all three languages enabled receives one notice per recipient/milestone in that recipient's resolved language, never three duplicate notices.
|
||
- Each delivery persists the resolved locale, company timezone, template key/version, render snapshot/inputs, and localization-fallback status; retries reproduce the same localized content.
|
||
- Changing a company/contact language does not resend completed milestones; the next unsent event uses the newly resolved language.
|
||
- The assigned admin's internal notice uses the admin preference while the call task exposes the company/contact language and the correct localized customer script.
|
||
- The call task remains open without an outcome; every outcome is audited; payment promise creates the next follow-up without resolving the invoice.
|
||
- Grace days 1–29 send one daily notice; day 30 sends one final-warning variant; the start of day 31 suspends once.
|
||
- The subscription retains full access throughout grace.
|
||
- Confirmed Stripe/manual payment immediately resolves collections, cancels tasks/queued notices, and wins safely against concurrent suspension.
|
||
- Pending/failed attempts, uploaded evidence, submitted evidence, and payment promises do not stop reminders.
|
||
- Active dispute/extension overrides prevent suspension; expiry/revocation resumes the correct schedule without duplicate or stale notices.
|
||
- Missing timezone/recipient/assignee fails safe, alerts finance, and does not auto-suspend.
|
||
|
||
### Authorization and boundary tests
|
||
|
||
- Non-owner company users cannot create manual subscription invoices.
|
||
- Owners cannot confirm their own payments through admin endpoints.
|
||
- Non-finance admins and stale/non-enrolled 2FA sessions cannot confirm payments.
|
||
- Cross-tenant invoice access fails without leaking existence.
|
||
- CSRF and rate-limit policies remain enforced.
|
||
- Owners cannot attach a document to another company’s invoice/submission or guess a storage key to download it.
|
||
- Only the uploading company and authorized finance admins can access evidence; each admin view/download is audited.
|
||
- Private evidence is not reachable through `/storage`, search, static assets, invoice PDFs, logs, or public URLs.
|
||
- Only authorized company owners can manage billing contacts; they cannot remove the last verified recipient.
|
||
- Call outcomes are limited to the assigned/authorized platform admin; overrides require authorized roles and fresh 2FA.
|
||
- The company-wide audience is never used for collections, and platform notices never leak into a company employee account.
|
||
|
||
### UI and end-to-end tests
|
||
|
||
- Stripe checkout still redirects and webhook success still activates exactly once.
|
||
- Bank transfer/check show correct configured instructions and create a pending invoice without activation.
|
||
- Customer can upload valid evidence, observe scanning, submit it for review, and cannot edit it afterward.
|
||
- Invalid/quarantined evidence produces a safe error without exposing scanner internals.
|
||
- Admin can securely inspect clean evidence, reject it with a reason, or proceed to confirmation.
|
||
- Billing contacts see the exact amount, original expiration, days remaining, final suspension time, and current payment action in the dashboard/email templates.
|
||
- Assigned platform admins receive the specified milestone channels, see the open 48-hour call task, and can record each required outcome.
|
||
- Payment confirmation during grace removes future reminders immediately; an already-queued delivery is suppressed by the final state check.
|
||
- Day-31 suspension and authorized dispute/extension behavior are covered end to end in a non-UTC company timezone.
|
||
- Admin confirmation dialog sends method, reference, full amount, date, note, verification, and stable idempotency key.
|
||
- Customer status changes from awaiting verification to paid/active after admin confirmation.
|
||
- English, French, and Arabic labels, validation errors, payment instructions, notification/email bodies, call scripts, and layouts are verified.
|
||
- Arabic screens and emails set the correct language/direction metadata and remain usable in RTL; English and French remain LTR.
|
||
- Amounts, dates, grace-day counts, and suspension times render correctly for each language in the company's configured timezone without changing stored financial/time values.
|
||
|
||
## 12. Rollout sequence
|
||
|
||
1. **Schema and observability:** deploy nullable payment/evidence fields, collections/contact/task/override tables, indexes, structured audit fields, metrics, and reference-safe logging.
|
||
2. **Recipient/timezone/language readiness:** collect and verify an IANA timezone, primary billing recipients, enabled/default communication languages, and assigned/fallback collections owner for every live account. Backfill from the existing supported brand/default locale, otherwise use English and flag for owner review. Do not enable automatic suspension for incomplete accounts.
|
||
3. **Private storage and scanning:** provision durable private storage, quarantine, malware scanning, retention controls, protected downloads, and orphan cleanup before accepting any customer document.
|
||
4. **Backfill and reconciliation:** create/verify canonical `BillingInvoice` links for all legacy subscription invoices; produce a reconciliation report before switching reads.
|
||
5. **Canonical service:** make Stripe and customer invoice reads use the canonical lifecycle while retaining temporary compatibility writes.
|
||
6. **Shadow collections worker:** calculate milestones and idempotency keys without sending or suspending; compare results against expected company-local dates and current invoices.
|
||
7. **Notifications/tasks:** complete Arabic/English/French template parity and locale-resolution tests, then enable internal collections inbox/call tasks and pre-expiration company notices. Verify recipient scoping, language snapshots, RTL rendering, and email deliverability.
|
||
8. **Evidence review and admin confirmation:** deploy private evidence endpoints, finance review queue, manual-payment confirmation, rejection, and atomic collections resolution behind feature flags.
|
||
9. **Grace/suspension enforcement:** enable daily grace notices, overrides, and finally day-31 suspension after shadow data shows zero timing/recipient/payment-state mismatches.
|
||
10. **Customer manual checkout:** expose bank/check and evidence upload only after instructions, storage/scanning, and finance operations are ready.
|
||
11. **Monitor:** track milestone lag, duplicates suppressed, recipient/config gaps, open/overdue call tasks, override expiry, daily delivery failures, grace age, blocked suspensions, scan failures, confirmation conflicts, invoice/subscription mismatches, and Stripe regressions.
|
||
12. **Retire compatibility:** remove lazy legacy sync and dual writes only after at least one billing cycle reconciles with zero mismatches.
|
||
|
||
Suggested flags:
|
||
|
||
- `manualSubscriptionPaymentsEnabled`
|
||
- `bankTransferEnabled`
|
||
- `checkPaymentEnabled`
|
||
- `manualPaymentEvidenceUploadEnabled`
|
||
- `subscriptionCollectionsNotificationsEnabled`
|
||
- `subscriptionGracePeriodEnforcementEnabled`
|
||
- `subscriptionAutomaticSuspensionEnabled`
|
||
|
||
## 13. Acceptance criteria
|
||
|
||
- An owner can choose Stripe, bank transfer, or check for a subscription purchase.
|
||
- A manual choice creates exactly one pending canonical invoice with server-calculated price and safe payment instructions.
|
||
- The owner can attach one to three valid private PDF/JPEG/PNG evidence documents and submit them to finance review.
|
||
- Uploaded files are content-validated, malware-scanned, stored privately, tenant-protected, and audited; rejected or unscannable files cannot be approved.
|
||
- The subscription is not activated when the invoice is created, when the customer reports a reference, or when evidence is uploaded/submitted.
|
||
- A finance admin with fresh 2FA can confirm a cleared full payment using a required method and reference.
|
||
- The admin can review clean evidence and reject it with a reason; approving a payment links the exact immutable submission and documents to the payment record.
|
||
- Confirmation is idempotent, concurrency-safe, duplicate-resistant, fully audited, and atomic.
|
||
- The paid invoice shown to the admin is the same invoice/status shown to the company owner.
|
||
- Full initial confirmation activates the requested plan; renewal confirmation extends from the original expiration boundary without discarding remaining paid days.
|
||
- Existing Stripe checkout/webhook behavior remains correct and does not double-activate.
|
||
- Only explicit company billing contacts/owners and the assigned platform collections admin receive payment notices.
|
||
- Every customer-facing subscription-payment communication is available in Arabic, English, and French and is sent once in the recipient's deterministic company-approved language.
|
||
- A company can enable one or more supported languages and select one default; contact preferences are constrained to the enabled set and otherwise inherit the default.
|
||
- Arabic customer UI/email uses RTL and correct `lang`/`dir` metadata; English and French use LTR. Dates, times, amounts, and currencies follow the resolved language and company timezone.
|
||
- A language change affects future unsent communications without duplicating past milestones; every delivery records the locale and template version used.
|
||
- Platform-admin tasks clearly identify the customer's language and provide the matching call script, even when the admin's internal interface uses a different preferred language.
|
||
- Pre-expiration notifications follow the 14-day, 7-day, 48-hour, and 24-hour channel matrix without duplicates.
|
||
- The 48-hour call task remains open until a valid outcome is recorded; payment promises create follow-up but do not resolve payment.
|
||
- An unpaid subscription remains fully active for the complete 30-day grace period and receives one targeted in-app/email notice per local day, with the day-30 notice serving as the final warning.
|
||
- At the start of local day 31, the system suspends exactly once only if payment remains unconfirmed and no authorized override is active.
|
||
- Confirmed payment immediately stops future reminders/tasks and keeps/restores active service, including when confirmation races with suspension.
|
||
- Timezone, idempotency, audit, disputes, extensions, and notification-delivery requirements are satisfied.
|
||
- The audit trail identifies every in-app notice, email delivery, call task, call outcome, payment confirmation, override, collections resolution, and suspension with actor and timestamp.
|
||
- All API, service, authorization, regression, and multilingual UI tests pass.
|
||
|
||
## 14. Required business inputs before implementation
|
||
|
||
These are configuration inputs, not reasons to weaken the design:
|
||
|
||
- Bank-transfer payer instructions: account name, bank name, RIB/IBAN or local account reference, and transfer memo rule.
|
||
- Check instructions: exact payee name, delivery address, and whether activation waits for deposit or full clearance. Recommended: full clearance.
|
||
- Manual-method payment/clearing lead times. The post-expiration subscription grace period is fixed at 30 days unless an authorized extension overrides it.
|
||
- Finance escalation owner for unmatched, short, excess, duplicate, or bounced payments.
|
||
- Collections assignment/fallback policy, overdue call-task escalation SLA, and which admin roles may act as collections owners.
|
||
- Required local reminder send time and verified source of each billing account’s IANA timezone.
|
||
- Whether the UI should expose generic base locales (`ar`, `en`, `fr`) while formatting for Morocco-specific conventions (`ar-MA`, `fr-MA`), and the regional convention to use for English. Recommended: store the three base communication choices and map them centrally to approved regional formatting locales.
|
||
- Approved professional Arabic, English, and French wording for payment instructions, legal/collections notices, evidence rejection, dispute/extension, grace-period warnings, call scripts, and suspension. Product/finance must approve all three versions before notifications are enabled.
|
||
- Override policy: maximum extension length, who may approve it, when normal daily notices pause, and dispute communication templates.
|
||
- Whether manual methods are available to every plan/company. Recommended MVP: configurable globally, with the ability to disable each method independently.
|
||
- Evidence retention/deletion period, legal-hold rules, and the person authorized to approve exceptional deletion. This must follow the applicable accounting/privacy jurisdiction.
|
||
- Approved malware-scanning service and production private-storage backend. Do not enable uploads if scanning or durable private storage is unavailable.
|