65 KiB
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:
- A company owner selects a plan and chooses Bank transfer or Check.
- The server calculates the price and creates one pending subscription invoice.
- The dashboard shows the amount, invoice number, due date, and configured payment instructions.
- The owner pays outside the application, enters the transaction reference/check number, and uploads supporting evidence such as a transfer receipt or check copy.
- The evidence is stored privately, scanned, and submitted to a finance review queue. Uploading evidence does not mark the invoice paid.
- The finance admin reviews the evidence and independently verifies that the transfer settled or the check cleared.
- The admin confirms the authoritative method and reference against the correct invoice.
- 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_TRANSFERandCHECK. - 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
/storagepath, 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
BillingInvoicethe canonical invoice. TreatSubscriptionInvoiceas 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
OWNERrole; never use theCOMPANY_EMPLOYEESaudience 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:
- Use the billing contact's explicit
localewhen it is one of the company's enabled communication languages. - Otherwise, for a linked employee, use that employee's preferred language when it is enabled for the company.
- Otherwise, use the company's
defaultCommunicationLocale. - 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_PENDINGwithout granting paid access. - Creating a renewal invoice must not change an active subscription to
PAYMENT_PENDINGor reduce current access. - A full admin-confirmed initial payment activates at confirmation time.
receivedAtremains 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
SUBMITTEDuntil it has at least oneCLEANdocument. - 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_DUEbegins when the renewal invoice enters the 14-day reminder window.- At expiration, an unpaid case enters
GRACE_PERIOD; the subscription remainsACTIVEwith full access. - Confirmed payment moves the case to
RESOLVED, cancels queued follow-up, and keeps/restores the subscriptionACTIVE. - At the company-local time boundary that begins day 31—30 calendar days after the exact expiration time—an unpaid, unpaused case moves to
SUSPENDEDand the subscription becomesSUSPENDED. - 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 | CHECKrequestedPlan: nullable plan enumrequestedBillingPeriod: nullable billing-period enummanualPaymentDueAt: nullable timestamp, or use the existingdueAtconsistentlylegacySubscriptionInvoiceId: 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 | OFFLINEmanualMethod: nullableBANK_TRANSFER | CHECKexternalReference: nullable original referencenormalizedExternalReference: nullable normalized referencereceivedAt: nullable settlement/clearance timeconfirmedAt: nullable admin-confirmation timeconfirmedByAdminId: nullable admin relationidempotencyKey: nullable UUIDnote: nullable, bounded text
ManualPaymentSubmission
Add a review entity separate from the successful payment record:
id,invoiceId,billingAccountId, andcompanyIdmethod:BANK_TRANSFER | CHECKsubmittedReferenceandnormalizedSubmittedReferencestatus:DRAFT | SUBMITTED | UNDER_REVIEW | APPROVED | REJECTEDsubmittedByEmployeeIdandsubmittedAtreviewedByAdminId,reviewedAt, and boundedrejectionReasonidempotencyKey- relation to the final
BillingPaymentAttemptafter 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,companyIdkind: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
timezonesuch asAfrica/Casablanca - nullable
collectionsOwnerAdminIdidentifying the internal owner - configurable reminder send time in local time; recommended default
09:00 enabledCommunicationLocales: a non-empty set containing onlyar,en, and/orfrdefaultCommunicationLocale: one of the enabled values
Add explicit billing recipients rather than broadcasting to the company:
BillingContact:billingAccountId, nullable linkedemployeeId, email,locale,isPrimary,receivePaymentNotices, and active/verified state- linked employees receive in-app and email; an external billing email can receive email only
- active
OWNERrecipients 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,companyIdstatus:SCHEDULED | PRE_DUE | GRACE_PERIOD | RESOLVED | SUSPENDED- immutable
originalExpirationAt graceStartedAt,finalSuspensionAt,resolvedAt, andsuspendedAtnextActionAtfor efficient worker queries- assigned
collectionsOwnerAdminId resolutionPaymentAttemptIdwhen paid- optimistic version or row-claim fields for concurrent workers
CollectionsCallTask
- one unique
PRE_EXPIRY_48H_CALLtask 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
pauseSuspensionand separately controlledpauseNotifications- extension-specific revised suspension date without changing
originalExpirationAt - only
FINANCE,ADMIN, orSUPER_ADMINwith 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)whenidempotencyKeyis 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 = OFFLINEand status isSUCCEEDED. - 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
- Add nullable fields and indexes first.
- Backfill
BillingInvoicerows from linkedSubscriptionInvoicerows. - Backfill collection method from the legacy provider.
- Verify counts, totals, currency, paid status, and links before changing reads.
- Switch customer and admin reads to
BillingInvoice. - Keep compatibility writes to
SubscriptionInvoiceonly during one release window if rollback requires them. - 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.
- Create collections cases only for canonical unpaid renewal invoices; do not infer them from unconfirmed payment attempts.
- 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:
{
"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
{
"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.
-
POST /api/v1/subscriptions/invoices/:invoiceId/manual-payment-submissionsCreates or returns a draft submission using a UUID idempotency key. Accept
methodandsubmittedReference; derive company and invoice ownership from authentication and the route. -
POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/documentsAccept one multipart field named
fileplus a documentkind. Only an owner from the same company may upload while the submission isDRAFT. -
DELETE /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentIdAllow only draft cleanup. After submission, documents are immutable and corrections must be appended through a replacement submission.
-
POST /api/v1/subscriptions/manual-payment-submissions/:submissionId/submitRequire 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.
-
GET /api/v1/subscriptions/manual-payment-submissions/:submissionId/documents/:documentIdStream 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_ROOTabstraction 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
/storagestatic route. Stream through protected API handlers or issue very short-lived, invoice-scoped signed downloads. - For raw files, set a sanitized
Content-Disposition, exact detectedContent-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=SUBMITTEDGET /api/v1/admin/billing/manual-payment-submissions/:submissionIdGET /api/v1/admin/billing/manual-payment-submissions/:submissionId/documents/:documentIdPOST /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
{
"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:
methodmust match the invoice collection method unless a finance override with a required reason is implemented.submissionIdmust 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.
amountmust equal the full current balance for subscription invoices.receivedAtcannot be unreasonably far in the future.fundsVerifiedmust 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
409for 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-contactsPUT /api/v1/subscriptions/billing-contactsGET /api/v1/subscriptions/communication-settingsPUT /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:
{
"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/:caseIdPATCH /api/v1/admin/billing/collections/:caseId/assigneePOST /api/v1/admin/billing/collection-tasks/:taskId/outcomesPOST /api/v1/admin/billing/collections/:caseId/overridesPOST /api/v1/admin/billing/collections/:caseId/overrides/:overrideId/revoke
Call-outcome payload:
{
"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:
- Resolve the idempotency key. Return the original result if the same request already succeeded.
- Read the canonical invoice, billing account, evidence submission, and document metadata with a write lock, or use conditional updates that detect concurrent changes.
- Validate tenant/account linkage, payable state, collection method, currency, full balance, requested subscription data, submitted evidence state, and that every attached document is clean.
- Confirm that the submission belongs to this invoice and has not already been approved/rejected. Evidence from another invoice must never be reusable.
- Normalize the admin-confirmed reference, compare it with the submitted reference, require a reason for a correction, and enforce the database uniqueness constraint.
- Create an immutable successful offline payment attempt with submission link, method, original/normalized confirmed reference, settlement time, admin identity, and idempotency key.
- Mark the evidence submission
APPROVEDand link it to that payment attempt. Evidence bytes remain immutable. - Conditionally update the invoice balance and status. The affected-row count must be exactly one.
- Apply the correct period rule: initial/lapsed purchase starts at confirmation; renewal starts at the case’s immutable
originalExpirationAtand extends from that anchor. Schedule renewal plan changes for the boundary rather than truncating the current period. - Clear payment-pending/past-due/suspension fields and retry counters.
- Mark the related collections case
RESOLVED, clearnextActionAt, and cancel any open call/follow-up tasks with the system reasonPAYMENT_CONFIRMED. - Write
payment_evidence.approved,invoice.paid,subscription.activated,collections.resolved, and admin audit records through the same transaction handle. - During the compatibility window, update the linked legacy
SubscriptionInvoicein the same transaction. - 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
OWNERusers. 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_PROMISEDmust 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
OWNERemployees. 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_EMPLOYEESfor 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_CALLtask and assign it to the collections owner. - The task remains
OPENuntil the admin recordsCONTACTED,NO_ANSWER,PAYMENT_PROMISED, orISSUE_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_CONFIRMEDand 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:
- Move the collections case to
GRACE_PERIOD, but keep the subscriptionACTIVEwith full access. - Send exactly one in-app notification and one email per local calendar day to each current billing recipient.
- Include amount due, original expiration date, current grace day, days remaining, final suspension time, and payment link/instructions.
- Use the day-30 daily notice as the final warning; do not send a second duplicate “final” notice that day.
- 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
nextActionAtand 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
ACTIVEwithout 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’sscheduledFor, andfinalSuspensionAtwhen 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_DISPUTEorMANUAL_EXTENSIONoverride pauses automatic suspension until its explicit expiry/revised suspension date. It must never mark the invoice paid. pauseNotificationsis 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_DUEmanual 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/CHECKon 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, andfr; 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
- Schema and observability: deploy nullable payment/evidence fields, collections/contact/task/override tables, indexes, structured audit fields, metrics, and reference-safe logging.
- 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.
- Private storage and scanning: provision durable private storage, quarantine, malware scanning, retention controls, protected downloads, and orphan cleanup before accepting any customer document.
- Backfill and reconciliation: create/verify canonical
BillingInvoicelinks for all legacy subscription invoices; produce a reconciliation report before switching reads. - Canonical service: make Stripe and customer invoice reads use the canonical lifecycle while retaining temporary compatibility writes.
- Shadow collections worker: calculate milestones and idempotency keys without sending or suspending; compare results against expected company-local dates and current invoices.
- 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.
- Evidence review and admin confirmation: deploy private evidence endpoints, finance review queue, manual-payment confirmation, rejection, and atomic collections resolution behind feature flags.
- Grace/suspension enforcement: enable daily grace notices, overrides, and finally day-31 suspension after shadow data shows zero timing/recipient/payment-state mismatches.
- Customer manual checkout: expose bank/check and evidence upload only after instructions, storage/scanning, and finance operations are ready.
- 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.
- Retire compatibility: remove lazy legacy sync and dual writes only after at least one billing cycle reconciles with zero mismatches.
Suggested flags:
manualSubscriptionPaymentsEnabledbankTransferEnabledcheckPaymentEnabledmanualPaymentEvidenceUploadEnabledsubscriptionCollectionsNotificationsEnabledsubscriptionGracePeriodEnforcementEnabledsubscriptionAutomaticSuspensionEnabled
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/dirmetadata; 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.