fix plan issues
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 5m29s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m9s
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 5m29s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m9s
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
# Security Hardening Applied
|
||||
|
||||
This bundle applies the highest-risk items from the hardened security fix plan to the uploaded apps source.
|
||||
|
||||
## Applied changes
|
||||
|
||||
- Added explicit subscription read/write/full middleware and moved sensitive mutations to write/full checks.
|
||||
- Hardened backend route authorization for reservations, customers, vehicles, analytics, payments, reviews, complaints, team, companies, offers, notifications, billing, and subscription actions.
|
||||
- Blocked AGENT access to manager/owner actions including reservation lifecycle transitions, analytics, payments, review replies/reminders, team lists/stats, license image reads, license validation, and complaint mutations.
|
||||
- Disabled public Swagger/OpenAPI exposure in production unless `ENABLE_PUBLIC_API_DOCS=true` is explicitly set.
|
||||
- Added CSRF-style Origin/Referer enforcement for cookie-authenticated browser mutations.
|
||||
- Added forwarding-header sanitization middleware and stripped spoofable forwarding headers from the dashboard API proxy.
|
||||
- Changed dashboard redirects to use the configured canonical dashboard URL instead of request-supplied forwarded host headers.
|
||||
- Blocked anonymous `/storage` access to reservation photo paths and classified reservation uploads as private storage.
|
||||
- Tightened image upload limits, file count limits, magic-byte validation, extension consistency, and basic image dimension limits.
|
||||
- Added a dedicated webhook rate limiter and raw body size limits.
|
||||
- Hardened AmanPay webhook signature verification so invalid signature length returns false instead of throwing.
|
||||
- Hardened webhook idempotency by rejecting missing provider event IDs and allowing stale PROCESSING records to retry after a timeout.
|
||||
- Hardened PayPal capture by binding capture to company, reservation, local pending payment status, provider completion status, amount, and currency.
|
||||
- Restricted public site/carplace booking flows to ACTIVE/TRIALING companies and AVAILABLE published vehicles.
|
||||
- Capped public Carplace pagination and metadata size.
|
||||
- Replaced rank-only admin role checks with explicit role allowlists so SUPPORT no longer implicitly outranks FINANCE.
|
||||
- Required fresh admin 2FA before replacing an already-enabled admin TOTP setup.
|
||||
- Started storing admin reset tokens and employee email verification tokens as hashes while preserving legacy raw-token lookup during migration.
|
||||
|
||||
## Validation performed
|
||||
|
||||
The sandbox did not include a usable installed workspace dependency tree, so full `npm test` / `npm run type-check` could not be executed from the extracted bundle. A TypeScript syntactic transpile check was run against all changed TypeScript files and passed.
|
||||
|
||||
## Important remaining work
|
||||
|
||||
This patch does not fully complete every item in the hardening plan. The remaining work should be handled before production sign-off:
|
||||
|
||||
- Add/expand DB-backed audit logging for every sensitive mutation.
|
||||
- Add database-backed idempotency for public booking instead of the current process-local cache.
|
||||
- Add full route authorization matrix tests across unauthenticated, AGENT, MANAGER, OWNER, wrong tenant, suspended, paused, and past-due states.
|
||||
- Add storage migration scripts to move existing public reservation photos into private storage.
|
||||
- Finish token migration for review tokens and any remaining invitation/public access tokens if schema support exists.
|
||||
- Add full payment provider merchant/receiver-account validation if provider config is stored per company.
|
||||
- Add dependency/lockfile CI gates from the plan.
|
||||
|
||||
@@ -269,7 +269,8 @@ async function createRequiredCallTask(caseData: any) {
|
||||
|
||||
async function getRenewalPrice(plan: string, billingPeriod: string) {
|
||||
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
|
||||
return configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
|
||||
const fallback = (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
|
||||
return Number.isInteger(configured?.amount) && configured!.amount > 0 ? configured!.amount : fallback
|
||||
}
|
||||
|
||||
export async function ensureRenewalCollectionsCases(now = new Date()) {
|
||||
|
||||
@@ -292,7 +292,7 @@ export async function ensurePrimaryBillingAccount(companyId: string, employeeId?
|
||||
async function resolvePrice(plan: string, billingPeriod: string) {
|
||||
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
|
||||
const fallback = (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
|
||||
const amount = configured?.amount ?? fallback
|
||||
const amount = Number.isInteger(configured?.amount) && configured!.amount > 0 ? configured!.amount : fallback
|
||||
if (!Number.isInteger(amount) || amount <= 0) throw new ValidationError('Invalid plan or billing period')
|
||||
return amount
|
||||
}
|
||||
|
||||
@@ -78,6 +78,22 @@ describe('subscription.service operational edges', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps catalog defaults when persisted pricing overrides are invalid', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 0 },
|
||||
{ plan: 'GROWTH', billingPeriod: 'ANNUAL', amount: null },
|
||||
{ plan: 'PRO', billingPeriod: 'ANNUAL', amount: -1 },
|
||||
{ plan: 'ENTERPRISE', billingPeriod: 'ANNUAL', amount: 575040 },
|
||||
] as never)
|
||||
|
||||
await expect(service.getPlans()).resolves.toEqual(expect.objectContaining({
|
||||
STARTER: expect.objectContaining({ ANNUAL: { MAD: 143040 } }),
|
||||
GROWTH: expect.objectContaining({ ANNUAL: { MAD: 287040 } }),
|
||||
PRO: expect.objectContaining({ ANNUAL: { MAD: 383040 } }),
|
||||
ENTERPRISE: expect.objectContaining({ ANNUAL: { MAD: 575040 } }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('starts trials only after enforcing one-trial-per-company policy', async () => {
|
||||
vi.mocked(repo.findByCompany).mockResolvedValue({ id: 'sub_old', trialUsed: true } as never)
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ export async function getPlans() {
|
||||
const configs = await prisma.pricingConfig.findMany()
|
||||
const result: Record<string, Record<string, Record<string, number>>> = structuredClone(PLAN_PRICES)
|
||||
for (const c of configs) {
|
||||
if (!Number.isInteger(c.amount) || c.amount <= 0) continue
|
||||
if (!result[c.plan]) result[c.plan] = {}
|
||||
result[c.plan]![c.billingPeriod] = { MAD: c.amount }
|
||||
result[c.plan]![c.billingPeriod] = { ...(result[c.plan]?.[c.billingPeriod] ?? {}), MAD: c.amount }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ async function getNextInvoiceSequence(tx: any) {
|
||||
|
||||
async function resolvePrice(plan: PlanCode, billingPeriod: BillingPeriodCode) {
|
||||
const configured = await prisma.pricingConfig.findUnique({ where: { plan_billingPeriod: { plan, billingPeriod } } })
|
||||
const amount = configured?.amount ?? (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
|
||||
const fallback = (PLAN_PRICES as any)[plan]?.[billingPeriod]?.MAD
|
||||
const amount = Number.isInteger(configured?.amount) && configured!.amount > 0 ? configured!.amount : fallback
|
||||
if (!Number.isInteger(amount) || amount <= 0) throw new ValidationError('Invalid plan or billing period')
|
||||
return amount
|
||||
}
|
||||
|
||||
@@ -139,7 +139,13 @@ const PLAN_LABELS: Record<Plan, string> = {
|
||||
ENTERPRISE: 'Enterprise',
|
||||
}
|
||||
|
||||
function mergePlanPrices(overrides?: Record<string, Record<string, Record<string, number>>> | null) {
|
||||
type PlanPriceOverrides = Record<string, Record<string, Record<string, unknown>>>
|
||||
|
||||
function isValidPriceAmount(amount: unknown): amount is number {
|
||||
return typeof amount === 'number' && Number.isInteger(amount) && amount > 0
|
||||
}
|
||||
|
||||
function mergePlanPrices(overrides?: PlanPriceOverrides | null) {
|
||||
const result: Record<string, Record<string, Record<string, number>>> = {
|
||||
STARTER: {
|
||||
MONTHLY: { ...PLAN_PRICES.STARTER.MONTHLY },
|
||||
@@ -162,7 +168,10 @@ function mergePlanPrices(overrides?: Record<string, Record<string, Record<string
|
||||
for (const [plan, periods] of Object.entries(overrides ?? {})) {
|
||||
result[plan] = result[plan] ?? {}
|
||||
for (const [period, currencies] of Object.entries(periods ?? {})) {
|
||||
result[plan]![period] = { ...(result[plan]?.[period] ?? {}), ...currencies }
|
||||
result[plan]![period] = { ...(result[plan]?.[period] ?? {}) }
|
||||
for (const [currencyCode, amount] of Object.entries(currencies ?? {})) {
|
||||
if (isValidPriceAmount(amount)) result[plan]![period]![currencyCode] = amount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +563,7 @@ export default function SubscriptionPage() {
|
||||
|
||||
const fetchPlanData = useCallback(async () => {
|
||||
const [prices, features] = await Promise.all([
|
||||
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
|
||||
apiFetch<PlanPriceOverrides>('/subscriptions/plans'),
|
||||
apiFetch<PlanFeature[]>('/subscriptions/features'),
|
||||
])
|
||||
setPlanPrices(mergePlanPrices(prices))
|
||||
@@ -984,7 +993,7 @@ export default function SubscriptionPage() {
|
||||
{isActive && <span className="badge-green">{copy.active}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-black text-slate-900 dark:text-zinc-100">
|
||||
{price ? formatCurrency(price, 'MAD') : '—'}
|
||||
{isValidPriceAmount(price) ? formatCurrency(price, 'MAD') : '—'}
|
||||
<span className="text-sm font-normal text-slate-500 dark:text-zinc-400">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
@@ -1069,7 +1078,7 @@ export default function SubscriptionPage() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-zinc-400">{copy.total}</p>
|
||||
<p className="text-xl font-black text-slate-900 dark:text-zinc-100">
|
||||
{payableAmount ? formatCurrency(payableAmount, 'MAD') : '—'}
|
||||
{isValidPriceAmount(payableAmount) ? formatCurrency(payableAmount, 'MAD') : '—'}
|
||||
{!upgradeProration ? (
|
||||
<span className="text-sm font-normal text-slate-500 ml-1 dark:text-zinc-400">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user