diff --git a/.env.docker.dev b/.env.docker.dev index 8428360..fc580e6 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002 DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard ADMIN_ASSET_PREFIX=http://localhost:3002/admin CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace -JWT_SECRET=placeholder +JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00 JWT_EXPIRY=8h RENTER_JWT_EXPIRY=7d ADMIN_SEED_EMAIL=rentaldrivego@gmail.com -ADMIN_SEED_PASSWORD=placeholder +ADMIN_SEED_PASSWORD=Qwerty00!@#$% ADMIN_SEED_FIRST_NAME=Platform ADMIN_SEED_LAST_NAME=Admin NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= @@ -34,17 +34,17 @@ NODE_ENV=development CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000 -# Email — Resend (primary) with SMTP fallback -# Get your API key at https://resend.com/api-keys +# Email — Gmail SMTP for local Docker development +EMAIL_PROVIDER=gmail RESEND_API_KEY=re_PLACEHOLDER EMAIL_FROM=noreply@rentaldrivego.ma EMAIL_FROM_NAME=RentalDriveGo -# SMTP fallback (only used if Resend fails or is unconfigured) +# Use a Gmail app password, not your normal Google account password. MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=placeholder +MAIL_PASSWORD=kfahihfzbcvkczew MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com @@ -55,4 +55,18 @@ MAIL_REPLY_TO_NAME=RentalDriveGo # STRIPE_API_KEY must be a Stripe secret/restricted key (sk_ or rk_). # STRIPE_WEBHOOK_SECRET must be a Stripe webhook signing secret (whsec_). STRIPE_API_KEY=sk_test_51TvTsb9SpDRZn9yJyBAlUSXcTp9zpwQfhJYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00OcYvj28K -STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211 \ No newline at end of file +STRIPE_WEBHOOK_SECRET=whsec_c5e0a6b2dd5e2f6ac804b428fe46f04e3af9b55c562f4124de20c52866f3c211 + +# Manual subscription payments for local development +MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true +MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true +PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean +BANK_TRANSFER_ENABLED=true +BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL +BANK_TRANSFER_BANK_NAME=Local Development Bank +BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000 +BANK_TRANSFER_DUE_DAYS=7 +CHECK_PAYMENT_ENABLED=true +CHECK_PAYMENT_PAYEE=RentalDriveGo SARL +CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk +CHECK_PAYMENT_DUE_DAYS=14 diff --git a/.env.docker.production.example b/.env.docker.production.example index 168fcb1..b823937 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -75,12 +75,13 @@ EMAIL_FROM_NAME=RentalDriveGo # Option A — Resend #RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V # Option B — SMTP (Gmail) -# SMTP fallback (only used if Resend fails or is unconfigured) +# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend. +EMAIL_PROVIDER=gmail MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=placeholder +MAIL_PASSWORD=your-16-character-gmail-app-password MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/.env.example b/.env.example index b0b0427..606b890 100644 --- a/.env.example +++ b/.env.example @@ -110,12 +110,14 @@ ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_LAST_NAME=Admin -# SMTP fallback (only used if Resend fails or is unconfigured) +# Email provider: auto, resend, smtp, or gmail. +# Use a Gmail app password for Gmail SMTP, not your normal Google account password. +EMAIL_PROVIDER=gmail MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_SCHEME=smtp MAIL_USERNAME=rentaldrivego@gmail.com -MAIL_PASSWORD=kfahihfzbcvkczew +MAIL_PASSWORD=your-16-character-gmail-app-password MAIL_FROM_ADDRESS=rentaldrivego@gmail.com MAIL_FROM_NAME=RentalDriveGo MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com diff --git a/MANUAL_SUBSCRIPTION_PAYMENTS.md b/MANUAL_SUBSCRIPTION_PAYMENTS.md new file mode 100644 index 0000000..61eb961 --- /dev/null +++ b/MANUAL_SUBSCRIPTION_PAYMENTS.md @@ -0,0 +1,23 @@ +# Manual subscription payments + +This project implements bank-transfer and check collection as first-class subscription billing paths. Evidence submission is explicitly **not** settlement: only a fresh-2FA finance confirmation of cleared funds creates a successful offline payment attempt, pays the canonical billing invoice, and activates or renews the subscription. + +## Safe rollout + +1. Apply the `20260809190000_manual_subscription_payments` migration and deploy with all new flags disabled. +2. Configure a private persistent, encrypted-at-rest `FILE_STORAGE_ROOT`; set both private-storage confirmation flags only after verifying the mounted storage; configure a fail-closed scanner executable, bank/check payer instructions, billing contacts, valid IANA timezones, and finance collections owners. +3. Enable `MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED`, then `MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED` and the desired manual method flags. +4. Enable `SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED`; verify the AR/EN/FR snapshots, outbox delivery, 48-hour call task, and escalation queue. +5. Enable `SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED` only after observing a full reminder/grace cycle. Suspension starts after 30 calendar grace days and rechecks payment, overrides, and configuration atomically. + +Manual methods remain hidden unless their instructions and evidence-scanning pipeline are ready. The non-production `PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean` option exists only for local tests; production requires `PAYMENT_EVIDENCE_SCANNER_PATH`. + +## Operational invariants + +- Subscription billing has one canonical `BillingInvoice`; legacy `SubscriptionInvoice` rows are compatibility links only. +- A scheduled renewal is reused by Stripe checkout rather than duplicated. +- Manual confirmation requires the full current MAD balance, matching collection method, clean same-invoice evidence, a unique normalized external reference, a unique idempotency key, a clearance timestamp, and a funds-verified attestation. +- Initial purchases activate from confirmation time. Renewals extend from the original expiration, including late confirmations after suspension. +- Evidence is stored privately with no public URL, quarantined before scanning, served only through authorized routes, and immutable after submission. +- Collections reminders are recipient-specific and localized using contact preference, linked employee preference, then company default. Only Arabic, English, and French can be enabled. +- Payment resolution cancels open call tasks and suppresses queued collections reminders. diff --git a/apps/Archive.zip b/apps/Archive.zip deleted file mode 100644 index 2ced688..0000000 Binary files a/apps/Archive.zip and /dev/null differ diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index c3666a9..5b1352d 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -10,7 +10,7 @@ const ADMIN_BASE_PATH = '/admin' // absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from // port 3002, bypassing the proxy (which can't upgrade WebSocket connections). const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH) -const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] }) +const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] }) const nextConfig = { basePath: ADMIN_BASE_PATH, diff --git a/apps/admin/src/app/dashboard/admin-users/page.tsx b/apps/admin/src/app/dashboard/admin-users/page.tsx index 45f320e..63079e4 100644 --- a/apps/admin/src/app/dashboard/admin-users/page.tsx +++ b/apps/admin/src/app/dashboard/admin-users/page.tsx @@ -9,6 +9,7 @@ interface AdminUser { lastName: string email: string role: string + preferredLocale: 'ar' | 'en' | 'fr' isActive: boolean createdAt: string permissions?: { id: string; resource: string; actions: string[] }[] @@ -21,6 +22,7 @@ const EMPTY_FORM = { email: '', password: '', role: 'SUPPORT', + preferredLocale: 'en', isActive: true, } @@ -68,6 +70,7 @@ export default function AdminUsersPage() { email: admin.email, password: '', role: admin.role, + preferredLocale: admin.preferredLocale, isActive: admin.isActive, }) setError(null) @@ -92,6 +95,7 @@ export default function AdminUsersPage() { lastName: form.lastName, email: form.email, role: form.role, + preferredLocale: form.preferredLocale, isActive: form.isActive, ...(form.password ? { password: form.password } : {}), } @@ -273,6 +277,18 @@ export default function AdminUsersPage() { {ROLES.map((r) => )} +
+ + +
setSearch(event.target.value)} placeholder={copy.searchPlaceholder} - className="w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 outline-none transition focus:border-emerald-500" + className="w-full bg-transparent text-sm text-zinc-100 outline-none" />
+
+ } title={copy.platformSettings} description={copy.pageIntro} /> +
+ + setPlatformTaxRate(event.target.value)} + className={FIELD_CLASS} + /> + + +
+ {platformBillingSettings?.updatedAt ? ( +

Last updated {new Date(platformBillingSettings.updatedAt).toLocaleString()}

+ ) : null} +
+ {stats && ( -
- +
- - - + + +
)} -
- {filters.map((filter) => ( - - ))} -
+ {reauthRequired ? ( + + ) : null} -
+
+ } + eyebrow={copy.workQueue} + title={copy.needsReview} + description={copy.needsReviewHelp} + aside={`${manualReviewItems.length} ${copy.open}`} + /> + {manualReviewItems.length === 0 ? ( + + ) : ( +
+ {manualReviewItems.slice(0, 6).map(({ account, invoice, submission }) => ( + + ))} +
+ )} +
+ +
-

{copy.billingAccounts}

+
+
+

{copy.billingAccounts}

+

{copy.accountListHelp}

+
+ {(search || statusFilter) ? ( + + ) : null} +
+
+ {filters.map((filter) => ( + + ))} +
{loadingList ? ( @@ -713,30 +1228,34 @@ export default function AdminBillingPage() { {accounts.map((account) => { const isSelected = selectedCompanyId === account.company.id const subscription = account.company.subscription + const pendingEvidenceCount = account.invoices.reduce((count, invoice) => + count + (invoice.manualPaymentSubmissions ?? []).filter((submission) => ['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)).length, + 0, + ) + const openInvoiceCount = account.invoices.filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status)).length return ( ) })} @@ -755,7 +1274,7 @@ export default function AdminBillingPage() { ) : detail ? ( <>
-
+

{copy.accountDetails}

{detail.company.name}

@@ -778,8 +1297,18 @@ export default function AdminBillingPage() {
+
+ } label={copy.invoiceReview} onClick={() => setDetailTab('review')} /> + } label={copy.accountSettings} onClick={() => setDetailTab('settings')} /> + } label={copy.createInvoice} onClick={() => setDetailTab('create')} /> + } label={copy.activity} onClick={() => setDetailTab('activity')} /> +
+
-
+ {detailTab === 'settings' ? ( +
+ } title={copy.accountSettings} description={copy.selectAccount} /> +
Net 60 -
+
-
- - - -
-
+
+ + + +
+ + ) : null} -
-
-

{copy.invoiceFeed}

- {isPending || loadingDetail ? {copy.loading} : null} -
- - {detail.invoices.length === 0 ? ( -
{copy.noInvoices}
- ) : ( -
-
- - - - - - - - - - - {detail.invoices.map((invoice) => ( - setSelectedInvoiceId(invoice.id)} - > - - - - - - ))} - -
Invoice{copy.amountDue}{copy.dueDate}{copy.actions}
-
-

{invoice.invoiceNumber ?? invoice.invoiceType}

-

{dateLabel(invoice.invoiceDate ?? invoice.createdAt)}

-
-
{money(invoice.amountDue, invoice.currency)}{dateLabel(invoice.dueAt)} - - {invoice.status} - -
-
- - {selectedInvoice && ( -
+ {detailTab === 'review' ? ( +
+ } + title={copy.invoiceReview} + description={copy.selectAccount} + aside={isPending || loadingDetail ? copy.loading : undefined} + /> + {detail.invoices.length === 0 ? ( + + ) : selectedInvoice ? ( +
-

{copy.selectedInvoice}

+

{copy.selectedInvoice}

-

{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}

-

+

{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}

+

{copy.issueDate}: {dateLabel(selectedInvoice.invoiceDate ?? selectedInvoice.createdAt)}

@@ -911,59 +1400,129 @@ export default function AdminBillingPage() {
+ {selectedManualSubmission ? ( +
+

{copy.stepSettlement}

+
+ {canConfirmSelectedManualPayment ? : } +
+

{canConfirmSelectedManualPayment ? copy.readyToConfirm : copy.cannotConfirmYet}

+

{copy.submittedReference}: {selectedManualSubmission.submittedReference}

+
+
+
+ ) : null} +
-

{copy.lineItems}

+

{copy.lineItems}

{selectedInvoice.lineItems.map((item, index) => ( -
+
-

{item.description}

-

+

{item.description}

+

{item.quantity} × {money(item.unitAmount, item.currency)}

-

{money(item.amount, item.currency)}

+

{money(item.amount, item.currency)}

))}
+ {selectedInvoice.manualPaymentSubmissions?.length ? ( +
+

{copy.stepEvidence}

+

{copy.customerEvidence}

+ {selectedInvoice.manualPaymentSubmissions.slice(0, 1).map((submission) => ( +
+

{copy.submittedReference}: {submission.submittedReference}

+

{submission.status} · {submission.submittedByEmployee.email} · {dateLabel(submission.submittedAt)}

+

{copy.downloadCleanFiles}

+ {submission.documents.map((documentItem) => ( +
+
+

{documentItem.originalFilename}

+

{documentItem.kind} · {documentItem.scanStatus}

+
+
+ + +
+
+ ))} + {['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status) ? ( + + ) : null} +
+ ))} +
+ ) : null} +
+

{copy.normalActions}

{selectedInvoice.status === 'DRAFT' ? ( ) : null} {['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(selectedInvoice.status) ? ( <> -
- setPayAmount(event.target.value)} - placeholder="Amount in cents" - className={FIELD_CLASS} - /> - -
+ {selectedInvoice.subscriptionId ? ( + selectedInvoice.collectionMethod === 'BANK_TRANSFER' || selectedInvoice.collectionMethod === 'CHECK' ? ( + + ) : ( +

{copy.stripeWebhookOnly}

+ ) + ) : ( +
+ setPayAmount(event.target.value)} + placeholder="Amount in minor units" + className={FIELD_CLASS} + /> + +
+ )} -
- setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
-
- setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
-
- setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> - setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> - -
+
+ {copy.riskActions} +
+
+ setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+
+ setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+
+ setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} /> + setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} /> + +
+
+
) : null} {['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(selectedInvoice.status) ? ( @@ -978,6 +1537,7 @@ export default function AdminBillingPage() { ) : null}
+

{copy.stepHistory}

({ id: attempt.id, primary: `${attempt.status} · ${money(attempt.amount, attempt.currency)}`, @@ -994,14 +1554,14 @@ export default function AdminBillingPage() { secondary: refund.reason, }))} />
- )} -
- )} -
+ ) : null} +
+ ) : null} -
-

{copy.draftInvoice}

-
+ {detailTab === 'create' ? ( +
+ } title={copy.draftInvoice} description={copy.selectAccount} /> +
setDraftLine(index, 'type', event.target.value)} className={FIELD_CLASS}> @@ -1058,9 +1618,9 @@ export default function AdminBillingPage() {
))} -
+
-
+
-
- +
+ + ) : null} -
-
- ({ - id: event.id, - primary: event.eventType, - secondary: `${event.source} · ${dateLabel(event.createdAt)}`, - }))} /> - ({ - id: entry.id, - primary: `${money(entry.amount, entry.currency)} · ${entry.type}`, - secondary: entry.reason, - }))} /> -
-
+ {detailTab === 'activity' ? ( +
+ } title={copy.activity} description={copy.stepHistory} /> +
+ ({ + id: event.id, + primary: event.eventType, + secondary: `${event.source} · ${dateLabel(event.createdAt)}`, + }))} /> + ({ + id: entry.id, + primary: `${money(entry.amount, entry.currency)} · ${entry.type}`, + secondary: entry.reason, + }))} /> +
+
+ ) : null} ) : null}
+ {evidenceViewer ? ( +
+
+
+
+

{copy.reviewEvidence}

+

{evidenceViewer.filename} · {evidenceViewer.mimeType}

+
+
+ + +
+
+
+ {evidenceViewer.isImage ? ( +
+ {evidenceViewer.filename} +
+ ) : ( +