fix production trial issue
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Failing after 5m31s
Test / API Unit Tests (push) Successful in 1m20s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m8s

This commit is contained in:
root
2026-07-29 16:05:01 -04:00
parent dc04ef07b9
commit 7bc1dd338a
4 changed files with 186 additions and 0 deletions
+89
View File
@@ -447,6 +447,95 @@ jobs:
ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')"
STRIPE_API_KEY_B64="$(printf '%s' "$STRIPE_API_KEY" | base64 | tr -d '\n')"
STRIPE_WEBHOOK_SECRET_B64="$(printf '%s' "$STRIPE_WEBHOOK_SECRET" | base64 | tr -d '\n')"
validate_stripe_secret_inputs() {
local env_file stripe_api_key stripe_webhook_secret
env_file="$(mktemp)"
trap 'rm -f "$env_file"' EXIT
if [ -n "$ENV_DOCKER_PRODUCTION_B64_CLEAN" ]; then
if ! printf '%s' "$ENV_DOCKER_PRODUCTION_B64_CLEAN" | base64 -d > "$env_file"; then
echo "::error::ENV_DOCKER_PRODUCTION_B64 is not valid base64"
exit 1
fi
elif [ -n "$ENV_DOCKER_PRODUCTION" ]; then
printf '%s' "$ENV_DOCKER_PRODUCTION" > "$env_file"
else
: > "$env_file"
fi
read_env_key() {
local key="$1"
awk -F= -v key="$key" '
/^[[:space:]]*#/ || index($0, "=") == 0 {
next
}
{
name = $1
sub(/^[[:space:]]*export[[:space:]]+/, "", name)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
if (name == key) {
value = substr($0, index($0, "=") + 1)
}
}
END {
print value
}
' "$env_file"
}
normalize_secret() {
local value="$1"
value="${value%$'\r'}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value#\"}"
value="${value%\"}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value#\'}"
value="${value%\'}"
fi
printf '%s' "$value"
}
stripe_api_key="$(normalize_secret "${STRIPE_API_KEY:-$(read_env_key STRIPE_API_KEY)}")"
stripe_webhook_secret="$(normalize_secret "${STRIPE_WEBHOOK_SECRET:-$(read_env_key STRIPE_WEBHOOK_SECRET)}")"
case "$stripe_api_key" in
sk_live_*|rk_live_*) ;;
"")
echo "::error::STRIPE_API_KEY is missing. Add a live sk_live_ or restricted rk_live_ key to the STRIPE_API_KEY secret, ENV_DOCKER_PRODUCTION, or ENV_DOCKER_PRODUCTION_B64."
exit 1
;;
placeholder|replace-with-*|*changeme*|*change-me*)
echo "::error::STRIPE_API_KEY is still a placeholder. Replace it with a live sk_live_ or restricted rk_live_ key in Gitea Actions secrets."
exit 1
;;
*)
echo "::error::STRIPE_API_KEY must start with sk_live_ or rk_live_ for production billing."
exit 1
;;
esac
case "$stripe_webhook_secret" in
whsec_*) ;;
"")
echo "::error::STRIPE_WEBHOOK_SECRET is missing. Add the Stripe webhook signing secret to the STRIPE_WEBHOOK_SECRET secret, ENV_DOCKER_PRODUCTION, or ENV_DOCKER_PRODUCTION_B64."
exit 1
;;
placeholder|replace-with-*|*changeme*|*change-me*)
echo "::error::STRIPE_WEBHOOK_SECRET is still a placeholder. Replace it with the Stripe webhook signing secret in Gitea Actions secrets."
exit 1
;;
*)
echo "::error::STRIPE_WEBHOOK_SECRET must start with whsec_."
exit 1
;;
esac
}
validate_stripe_secret_inputs
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
@@ -375,4 +375,55 @@ describe('menu.service', () => {
'settings',
])
})
it('adds baseline routes when a full-access trial menu only exposes a small recovery menu', async () => {
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
id: 'employee_1',
role: 'OWNER',
isActive: true,
companyId: 'company_1',
} as never)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'TRIALING',
subscription: { plan: 'STARTER', status: 'TRIALING' },
} as never)
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
{
id: 'item_notifications',
systemKey: 'notifications',
label: 'Notifications',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/notifications',
icon: 'Bell',
parentId: null,
openInNewTab: false,
isRequired: false,
isActive: true,
displayOrder: 120,
roleVisibilities: [{ role: 'OWNER' }],
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 120, isActive: true }],
companyAssignments: [],
},
] as never)
const result = await getEmployeeMenu('employee_1')
expect(result.subscriptionStatus).toBe('TRIALING')
expect(result.subscriptionAccessLevel).toBe('full')
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
'dashboard',
'reservations',
'contracts',
'fleet',
'customers',
'reports',
'billing',
'settings',
'notifications',
])
})
})
+42
View File
@@ -621,6 +621,46 @@ function buildBaselineEmployeeMenu(role: EmployeeRole) {
}))
}
function routeKey(routeOrUrl: string | null) {
return routeOrUrl === '/' ? '/' : routeOrUrl?.replace(/\/+$/, '')
}
function hasOnlyRecoveryOrUtilityRoutes(items: ReturnType<typeof buildMenuTree>) {
const internalRoutes = items
.filter((item) => item.itemType === 'INTERNAL_PAGE')
.map((item) => routeKey(item.routeOrUrl))
.filter((route): route is string => Boolean(route))
if (internalRoutes.length === 0) return false
return internalRoutes.every((route) => (
route === '/' ||
route === '/notifications' ||
MENU_RECOVERY_ROUTES.has(route)
))
}
function mergeWithBaselineEmployeeMenu(items: ReturnType<typeof buildMenuTree>, role: EmployeeRole) {
const merged = [...items]
const seenRoutes = new Set(
merged
.filter((item) => item.itemType === 'INTERNAL_PAGE')
.map((item) => routeKey(item.routeOrUrl))
.filter(Boolean),
)
const seenSystemKeys = new Set(merged.map((item) => item.systemKey).filter(Boolean))
for (const baselineItem of buildBaselineEmployeeMenu(role)) {
const key = routeKey(baselineItem.routeOrUrl)
if (seenRoutes.has(key) || seenSystemKeys.has(baselineItem.systemKey)) continue
merged.push(baselineItem)
seenRoutes.add(key)
seenSystemKeys.add(baselineItem.systemKey)
}
return sortByDisplayOrder(merged)
}
export async function previewCompanyMenu(input: MenuPreviewInput) {
const context = await getMenuEvaluationContext(input.companyId, input.role)
return {
@@ -677,6 +717,8 @@ export async function getEmployeeMenu(employeeId: string) {
const resolvedItems =
context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems)
? buildBaselineEmployeeMenu(employee.role)
: context.subscriptionAccessLevel === 'full' && employee.isActive && hasOnlyRecoveryOrUtilityRoutes(menuItems)
? mergeWithBaselineEmployeeMenu(menuItems, employee.role)
: menuItems
return {
+4
View File
@@ -161,6 +161,8 @@ Open `.env.docker.production` and fill in every value. The minimum required secr
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
| `STRIPE_API_KEY` | Live Stripe secret key or restricted key for production billing (`sk_live_` or `rk_live_`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret for `/api/v1/subscriptions/webhooks/stripe` (`whsec_`) |
For Gitea Actions deploys, either store the completed production env file as the raw `ENV_DOCKER_PRODUCTION` secret or as the base64-encoded `ENV_DOCKER_PRODUCTION_B64` secret:
@@ -170,6 +172,8 @@ base64 < .env.docker.production | tr -d '\n'
Paste that single-line output into `ENV_DOCKER_PRODUCTION_B64` when using the base64 option. During deploy, the workflow writes the env file to `/opt/rentaldrivego/.env.docker.production` on the VPS with `600` permissions before running `scripts/docker-prod-deploy.sh`. If neither production env secret is set, the workflow reuses `/opt/rentaldrivego/.env.docker.production` when it already exists on the VPS.
You can also store `STRIPE_API_KEY` and `STRIPE_WEBHOOK_SECRET` as separate Gitea Actions secrets. When those secrets are present, the deploy workflow overwrites the Stripe values from `ENV_DOCKER_PRODUCTION`/`ENV_DOCKER_PRODUCTION_B64` before deploying. This is useful when the production env file secret still contains placeholders for billing secrets.
Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`.
The example file uses `rentaldrivego.ma` for the carplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.