48 Commits

Author SHA1 Message Date
Codex 2e04260ebd revert(payment): remove unsupported Stripe and EzPay upgrade
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
2026-07-29 18:15:20 +08:00
Codex ba6d3605b7 ci: encode domestic SSH key secret 2026-07-29 18:15:20 +08:00
Codex 17fad9ecc0 ci: add isolated domestic frontend deployment 2026-07-29 18:15:20 +08:00
Codex fbcd5abe22 feat(external-entry): define Facebook business links 2026-07-29 18:15:20 +08:00
Codex dae04f75dc fix(payment): restore wallet order and English labels
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-29 15:29:22 +08:00
Codex 8f8e067d82 fix(payment): allow Stripe after closing QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m4s
2026-07-29 14:33:53 +08:00
Codex 3c74d30189 fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
2026-07-29 13:11:33 +08:00
Codex 4cbdd0da7c fix(payment): resume QRIS after closing dialog
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-29 12:27:56 +08:00
Codex e071f83474 feat(payment): confirm renewal only at checkout
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
2026-07-29 11:32:23 +08:00
Codex 3536045794 fix(payment): inherit chat recipient without selector
Docker Image / Build and Push Docker Image (push) Successful in 2m42s
2026-07-28 19:37:58 +08:00
Codex 74b7eae18b feat(payment): add chat support discounts and gratitude
Docker Image / Build and Push Docker Image (push) Successful in 2m14s
(cherry picked from commit ef9b79bc83)
2026-07-28 17:54:36 +08:00
Codex 59e4eac736 feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
2026-07-28 16:48:11 +08:00
Codex 2e402de15b feat(payment): accept support character guidance scene
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-28 12:16:28 +08:00
Codex a530850039 fix(payment): hide first recharge banner
Docker Image / Build and Push Docker Image (push) Successful in 2m11s
2026-07-28 11:15:21 +08:00
Codex a2cf62f78b feat(private-zone): default to albums tab
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-27 19:05:58 +08:00
Codex 67f292353e fix(payment): portal checkout dialogs to viewport
Docker Image / Build and Push Docker Image (push) Successful in 3m32s
2026-07-27 18:53:37 +08:00
Codex 8c0d1ad3ce feat(chat): render role-bound payment guidance
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
2026-07-27 18:21:13 +08:00
Codex 019caae598 feat(auth): add Messenger topup handoff entry
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
2026-07-27 17:25:31 +08:00
Codex 73e6f341fb fix(ci): deploy frontend production as lzf
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
2026-07-27 16:58:07 +08:00
Codex d6f6bc2d87 test(chat): enforce locked image URL masking
Docker Image / Build and Push Docker Image (push) Successful in 2m15s
2026-07-27 15:44:17 +08:00
Codex b34d3a3a67 feat(subscription): separate selection from payment and add issue feedback
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
(cherry picked from commit fe9d31146b)
2026-07-27 11:42:49 +08:00
Codex 9602fdd94d fix(external-entry): preserve legacy private room links
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-27 11:31:59 +08:00
Codex bb1f0d225c feat(chat): send idempotent client message ids
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
2026-07-27 11:00:37 +08:00
Codex 0d5b5c17fa feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-24 20:19:49 +08:00
Codex 30ab2c2c97 feat(chat): add support action cards and live entitlements
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
2026-07-24 18:08:21 +08:00
Codex 17236bd14e feat(call): add streaming voice call experience
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
2026-07-24 16:42:21 +08:00
Codex 5e0361a199 fix(ci): scope frontend image cleanup
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
2026-07-24 13:17:20 +08:00
Codex 8660fe7b7e fix(pwa): 统一桌面安装图标
Docker Image / Build and Push Docker Image (push) Successful in 2m6s
(cherry picked from commit 29abd746da)
2026-07-24 12:19:40 +08:00
Codex a20a333665 feat(mobile): improve image viewer and install entry
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
(cherry picked from commit 556bfd2919)
2026-07-24 11:58:13 +08:00
Codex 92768047e9 fix(chat): debounce post-send action menu
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-23 20:07:17 +08:00
Codex 4dae805a88 feat(payment): support user-bound seven-discount offers
Docker Image / Build and Push Docker Image (push) Successful in 1m55s
2026-07-23 16:13:22 +08:00
Codex 02f6964484 feat(chat): render commercial actions and persist greetings
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
2026-07-23 14:54:23 +08:00
Codex 79397af739 feat(splash): simplify multi-character home layout
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-23 13:33:17 +08:00
Codex 770ce6b8fd fix(private-zone): hide incomplete albums and refresh Maya cover
Docker Image / Build and Push Docker Image (push) Successful in 1m59s
2026-07-23 11:51:14 +08:00
Codex 0357fbcaff refactor(private-zone): use canonical product name
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-23 10:55:47 +08:00
Codex 30122a44db fix(multi-role): enable Maya and Nayeli commercial flows
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
2026-07-23 10:24:13 +08:00
Codex 2bb3da829e chore(release): promote favorite and Menu navigation to pre
Docker Image / Build and Push Docker Image (push) Successful in 1m55s
2026-07-22 19:38:11 +08:00
Codex fd2291fa62 docs(release): remove Private Zoom legacy literal [skip ci] 2026-07-22 19:34:48 +08:00
Codex 36e5dd3c96 docs(release): sync verified Private Zoom rollout [skip ci] 2026-07-22 19:33:03 +08:00
Codex 7103652829 chore(release): promote pnpm bootstrap fix to pre
Docker Image / Build and Push Docker Image (push) Successful in 5m56s
2026-07-22 19:29:53 +08:00
Codex dff112eadc chore(release): promote Docker registry fix to pre
Docker Image / Build and Push Docker Image (push) Failing after 1m12s
2026-07-22 19:27:22 +08:00
Codex 437ac3460b chore(release): promote Indonesia QRIS to pre
Docker Image / Build and Push Docker Image (push) Failing after 3m46s
2026-07-22 18:58:54 +08:00
Codex 35939e7681 chore(release): update Private Zoom rollout guide
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
2026-07-22 18:52:48 +08:00
Codex a39d3c25ce chore(release): promote Private Zoom rename to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m34s
2026-07-22 18:31:36 +08:00
Codex a317a46e87 chore(release): promote E2E fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-22 16:53:48 +08:00
Codex b531b02a85 chore(release): promote dev fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 4m0s
2026-07-22 15:40:23 +08:00
Codex adfb230908 chore(release): rename pre-release branch to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
2026-07-22 10:56:59 +08:00
admin 291f268cce chore(favicon): 使用预发布环境的图标
Docker Image / Build and Push Docker Image (push) Successful in 3m47s
2026-07-22 10:06:31 +08:00
20 changed files with 219 additions and 622 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- test - pre
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -48,7 +48,7 @@ jobs:
DEPLOY_ENV="prod" DEPLOY_ENV="prod"
NEXT_ENV_FILE=".env.production" NEXT_ENV_FILE=".env.production"
;; ;;
test) pre)
DEPLOY_ENV="test" DEPLOY_ENV="test"
NEXT_ENV_FILE=".env.local" NEXT_ENV_FILE=".env.local"
;; ;;
+2 -2
View File
@@ -72,12 +72,12 @@ copy_env_by_branch() {
START_PORT=9185 START_PORT=9185
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE" echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
;; ;;
test) pre)
cp -f env-example/.env.local.example .env.local cp -f env-example/.env.local.example .env.local
ENV_FILE=".env.local" ENV_FILE=".env.local"
DEPLOY_ENV="test" DEPLOY_ENV="test"
START_PORT=9135 START_PORT=9135
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE" echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
;; ;;
dev) dev)
cp -f env-example/.env.development.example .env.local cp -f env-example/.env.development.example .env.local
+8 -8
View File
@@ -12,7 +12,7 @@
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 | | 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` | | 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` | | 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
当前 remote 配置示例: 当前 remote 配置示例:
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
## 本地发布流程 ## 本地发布流程
测试环境发布流程(脚本迁移前): 预发环境发布流程(脚本迁移前):
1. 进入 test worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test` 1. 进入 pre worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
2. 确认当前分支。 2. 确认当前分支。
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。 3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
4. 复制测试环境图标到 `public/` 4. 复制测试环境图标到 `public/`
5. 复制 `env-example/.env.local.example``.env.local` 5. 复制 `env-example/.env.local.example``.env.local`
6. 执行旧版 `scripts/deploy/deploy_web_test.sh` 6. 执行旧版 `scripts/deploy/deploy_web_test.sh`
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。 7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
8. 推送成功后尝试清除 Cloudflare CDN 缓存。 8. 推送成功后尝试清除 Cloudflare CDN 缓存。
旧生产环境发布流程(脚本迁移前): 旧生产环境发布流程(脚本迁移前):
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 | | 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `main` | `env-example/.env.production.example``.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` | | `main` | `env-example/.env.production.example``.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
| `test` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` | | `pre` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
| `dev` | `env-example/.env.development.example``.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` | | `dev` | `env-example/.env.development.example``.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` | | 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。 1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。 2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
3. `main` 分支对应生产环境,`test` 分支对应测试环境。 3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
4. 生产推送当前使用 `git push --force production main` 4. 生产推送当前使用 `git push --force production main`
5. 构建失败时不会重启容器,旧容器继续运行。 5. 构建失败时不会重启容器,旧容器继续运行。
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start` 6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。 7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。 8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
## 后续优化建议 ## 后续优化建议
+7 -7
View File
@@ -8,13 +8,13 @@
| Workflow | 触发时机 | 职责 | | Workflow | 触发时机 | 职责 |
| --- | --- | --- | | --- | --- | --- |
| `.gitea/workflows/ci.yml` | `dev``main``test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 | | `.gitea/workflows/ci.yml` | `dev``main``pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
| `.gitea/workflows/docker-image.yml` | `main``test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 | | `.gitea/workflows/docker-image.yml` | `main``pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。 这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test` `main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
分支快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的 分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
`ci.yml` 会同时执行完整检查并提供测试结果。 `ci.yml` 会同时执行完整检查并提供测试结果。
## 必需 Secrets ## 必需 Secrets
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
## 镜像 Tag 规则 ## 镜像 Tag 规则
`test` 分支发布: `pre` 分支发布时继续使用现有 `test-*` 运行镜像标签
```bash ```bash
REGISTRY_IMAGE:test-<short_sha> REGISTRY_IMAGE:test-<short_sha>
@@ -81,7 +81,7 @@ volumes:
## 验证方式 ## 验证方式
推送到 `test` 分支后,检查 Actions 日志中是否出现: 推送到 `pre` 分支后,检查 Actions 日志中是否出现:
```text ```text
Build Docker image Build Docker image
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。 - `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。 - 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*` SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`
+7 -7
View File
@@ -3,7 +3,7 @@
当前推荐部署链路: 当前推荐部署链路:
```text ```text
push test/main push pre/main
→ Gitea Actions 构建并推送 Docker 镜像 → Gitea Actions 构建并推送 Docker 镜像
→ Actions 通过 SSH 登录目标服务器 → Actions 通过 SSH 登录目标服务器
→ 目标服务器 docker pull 指定镜像 tag → 目标服务器 docker pull 指定镜像 tag
@@ -16,7 +16,7 @@ push test/main
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 | | 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` | | `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` | | `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
## Gitea Secrets ## Gitea Secrets
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。 `test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>` 部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`
## 本地发布入口 ## 本地发布入口
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions 本地发布脚本现在只负责推送 Gitea 分支,触发 Actions
```bash ```bash
# 测试环境 # 预发环境
./scripts/release/pre_release_web.sh ./scripts/release/pre_release_web.sh
# 生产环境 # 生产环境
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
也可以直接推送: 也可以直接推送:
```bash ```bash
git push gitea test git push gitea pre
git push --force gitea main git push --force gitea main
``` ```
## 验证 ## 验证
测试环境: 预发环境:
```bash ```bash
ssh <user>@<test-host> ssh <user>@<pre-host>
cd /opt/cozsweet-web-test cd /opt/cozsweet-web-test
docker compose ps docker compose ps
curl -I http://127.0.0.1:9135/ curl -I http://127.0.0.1:9135/
+2 -2
View File
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome. For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token `pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
on every pull request and `dev` / `test` / `main` push. request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
## Environment overrides ## Environment overrides
+30 -16
View File
@@ -283,14 +283,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 10_000 });
}); });
test("closing QRIS restores payment selection and allows switching to Stripe", async ({ test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
page, page,
}) => { }) => {
const payment = await registerIndonesiaPaymentMocks(page); const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page); await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio"); await page.goto("/subscription?type=topup&character=elio");
await expectQrisOrder( const orderId = await expectQrisOrder(
page, page,
/Pay and Top Up/i, /Pay and Top Up/i,
"dol_1000", "dol_1000",
@@ -300,14 +300,20 @@ test("closing QRIS restores payment selection and allows switching to Stripe", a
await dialog.getByRole("button", { name: "Close" }).click(); await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled(); const resumeButton = page.getByRole("button", {
await page.getByRole("button", { name: "Stripe" }).click(); name: "Resume QRIS payment",
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute( });
"aria-pressed", await expect(resumeButton).toBeEnabled();
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1); expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
}); });
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({ test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
@@ -402,7 +408,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await page.goto("/characters/elio/tip"); await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
await expectQrisOrder( const orderId = await expectQrisOrder(
page, page,
/Order and Buy/i, /Order and Buy/i,
"tip_coffee_usd_4_99", "tip_coffee_usd_4_99",
@@ -412,11 +418,19 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await dialog.getByRole("button", { name: "Close" }).click(); await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled(); const resumeButton = page.getByRole("button", {
await page.getByRole("button", { name: "Stripe" }).click(); name: "Resume QRIS payment",
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute( });
"aria-pressed", await expect(resumeButton).toBeEnabled();
"true", await resumeButton.click();
); await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1); expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
}); });
@@ -1,153 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
+1 -1
View File
@@ -3,7 +3,7 @@
# 推送指定分支到指定远端。 # 推送指定分支到指定远端。
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。 # 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
# 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main" # 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main"
# deploy_web_test.shtest)→ push_to_remote "gitea" "test" # deploy_web_test.shpre)→ push_to_remote "gitea" "pre"
push_to_remote() { push_to_remote() {
local remote="$1" local remote="$1"
local branch="$2" local branch="$2"
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ========================================== # ==========================================
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型) # 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
# 原始 Dart: scripts/deploy/deploy_web_test.sh # 原始 Dart: scripts/deploy/deploy_web_test.sh
# ========================================== # ==========================================
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
# 主函数 # 主函数
main() { main() {
push_to_remote "gitea" "test" push_to_remote "gitea" "pre"
} }
main main
+8 -8
View File
@@ -11,9 +11,9 @@ unset_git_hook_env() {
unset_git_hook_env unset_git_hook_env
# Test 分支 worktree 路径 # pre 分支 worktree 路径
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test" WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
echo "=== 进入 test 分支 worktree ===" echo "=== 进入 pre 分支 worktree ==="
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; } cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
echo "=== 检查当前分支 ===" echo "=== 检查当前分支 ==="
@@ -22,17 +22,17 @@ git branch --show-current
echo "=== 变基到 dev 分支 ===" echo "=== 变基到 dev 分支 ==="
git rebase dev git rebase dev
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/ # 复制预发环境图标(运行资源目录仍为 icons/test
echo "=== 复制测试环境图标(icons/test → public/ ===" echo "=== 复制预发环境图标(icons/test → public/ ==="
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico" cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png" cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png" cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local # 准备预发环境变量(运行配置仍使用 .env.local
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local ===" echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local ==="
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local" cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
echo "=== 执行构建部署脚本(测试环境)===" echo "=== 执行构建部署脚本(预发环境)==="
./scripts/deploy/deploy_web_test.sh ./scripts/deploy/deploy_web_test.sh
echo "=== 预发布完成 ===" echo "=== 预发布完成 ==="
@@ -7,15 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = { const hiddenLaunch = {
handleEzpayCancel: vi.fn(), handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(), handleEzpayConfirm: vi.fn(),
handleRegionalQrClose: vi.fn(), handleQrisClose: vi.fn(),
handleStripeClose: vi.fn(), handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(), handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false, isConfirmingEzpay: false,
regionalQrErrorMessage: null, qrisErrorMessage: null,
regionalQrPayment: null, qrisPayment: null,
regionalQrStatus: null, qrisStatus: null,
shouldShowEzpayConfirmDialog: false, shouldShowEzpayConfirmDialog: false,
shouldShowRegionalQrDialog: false, shouldShowQrisDialog: false,
shouldShowStripeDialog: false, shouldShowStripeDialog: false,
stripeClientSecret: null, stripeClientSecret: null,
}; };
@@ -96,15 +96,14 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment." ezpayDescription="Scan QRIS to finish the payment."
launch={{ launch={{
...hiddenLaunch, ...hiddenLaunch,
regionalQrPayment: { qrisPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW", qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris", orderId: "order-id-qris",
amountCents: 5_000_000, amountCents: 5_000_000,
currency: "IDR", currency: "IDR",
experience: "qris",
}, },
regionalQrStatus: "pending", qrisStatus: "pending",
shouldShowRegionalQrDialog: true, shouldShowQrisDialog: true,
}} }}
/>, />,
), ),
@@ -123,44 +122,6 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Waiting for payment"); expect(dialog?.textContent).toContain("Waiting for payment");
}); });
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => { it("shows QRIS failure state and handles empty QR data safely", () => {
act(() => act(() =>
root.render( root.render(
@@ -170,16 +131,15 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment." ezpayDescription="Scan QRIS to finish the payment."
launch={{ launch={{
...hiddenLaunch, ...hiddenLaunch,
regionalQrErrorMessage: "Payment failed or was cancelled.", qrisErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: { qrisPayment: {
qrData: "", qrData: "",
orderId: "order-id-qris", orderId: "order-id-qris",
amountCents: 5_000_000, amountCents: 5_000_000,
currency: "IDR", currency: "IDR",
experience: "qris",
}, },
regionalQrStatus: "failed", qrisStatus: "failed",
shouldShowRegionalQrDialog: true, shouldShowQrisDialog: true,
}} }}
/>, />,
), ),
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow, PaymentLaunchFlow,
| "handleEzpayCancel" | "handleEzpayCancel"
| "handleEzpayConfirm" | "handleEzpayConfirm"
| "handleRegionalQrClose" | "handleQrisClose"
| "handleStripeClose" | "handleStripeClose"
| "handleStripeConfirmed" | "handleStripeConfirmed"
| "isConfirmingEzpay" | "isConfirmingEzpay"
| "regionalQrErrorMessage" | "qrisErrorMessage"
| "regionalQrPayment" | "qrisPayment"
| "regionalQrStatus" | "qrisStatus"
| "shouldShowEzpayConfirmDialog" | "shouldShowEzpayConfirmDialog"
| "shouldShowRegionalQrDialog" | "shouldShowQrisDialog"
| "shouldShowStripeDialog" | "shouldShowStripeDialog"
| "stripeClientSecret" | "stripeClientSecret"
>; >;
@@ -53,12 +53,12 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm} onConfirm={launch.handleEzpayConfirm}
/> />
) : null} ) : null}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? ( {launch.shouldShowQrisDialog && launch.qrisPayment ? (
<RegionalQrPaymentDialog <QrisPaymentDialog
payment={launch.regionalQrPayment} payment={launch.qrisPayment}
status={launch.regionalQrStatus} status={launch.qrisStatus}
errorMessage={launch.regionalQrErrorMessage} errorMessage={launch.qrisErrorMessage}
onClose={launch.handleRegionalQrClose} onClose={launch.handleQrisClose}
/> />
) : null} ) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( {launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
@@ -73,56 +73,43 @@ export function PaymentLaunchDialogs({
); );
} }
interface RegionalQrPaymentDialogProps { interface QrisPaymentDialogProps {
errorMessage: string | null; errorMessage: string | null;
onClose: () => void; onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>; payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"]; status: PaymentLaunchFlow["qrisStatus"];
} }
function formatRegionalQrAmount(amountCents: number, currency: string): string { function formatQrisAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR"; const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try { try {
return new Intl.NumberFormat( return new Intl.NumberFormat("id-ID", {
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
{
style: "currency", style: "currency",
currency: normalizedCurrency, currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2, maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
}, }).format(amountCents / 100);
).format(amountCents / 100);
} catch { } catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`; return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
} }
} }
function regionalQrStatusMessage( function qrisStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"], status: PaymentLaunchFlow["qrisStatus"],
errorMessage: string | null, errorMessage: string | null,
paymentName: string,
): string { ): string {
if (status === "failed") { if (status === "failed") return errorMessage || "Payment failed. Please try again.";
return errorMessage || "Payment failed. Please try again."; if (status === "expired") return errorMessage || "This QRIS order has expired.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment"; return "Waiting for payment";
} }
function RegionalQrPaymentDialog({ function QrisPaymentDialog({
errorMessage, errorMessage,
onClose, onClose,
payment, payment,
status, status,
}: RegionalQrPaymentDialogProps) { }: QrisPaymentDialogProps) {
const titleId = useId(); const titleId = useId();
const copy = regionalQrCopy(payment.experience); const statusMessage = qrisStatusMessage(status, errorMessage);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return ( return (
<ModalPortal <ModalPortal
@@ -136,17 +123,17 @@ function RegionalQrPaymentDialog({
> >
<div className={`${styles.header} text-center`}> <div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}> <h2 id={titleId} className={styles.title}>
{copy.title} Scan to pay with QRIS
</h2> </h2>
<p className={styles.content}> <p className={styles.content}>
{copy.description} Open a QRIS-compatible banking or wallet app and scan this code.
</p> </p>
</div> </div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4"> <div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? ( {payment.qrData ? (
<QRCodeSVG <QRCodeSVG
value={payment.qrData} value={payment.qrData}
title={copy.qrTitle} title="QRIS payment QR code"
size={256} size={256}
level="M" level="M"
marginSize={2} marginSize={2}
@@ -154,14 +141,14 @@ function RegionalQrPaymentDialog({
/> />
) : ( ) : (
<p className={styles.error} role="alert"> <p className={styles.error} role="alert">
{copy.unavailableMessage} QRIS data is unavailable. Please close this dialog and try again.
</p> </p>
)} )}
</div> </div>
<div className="mb-4 flex flex-col gap-2 text-center"> <div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p> <p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground"> <p className="m-0 text-xl font-bold text-text-foreground">
{formatRegionalQrAmount(payment.amountCents, payment.currency)} {formatQrisAmount(payment.amountCents, payment.currency)}
</p> </p>
<p <p
className={status === "failed" || status === "expired" ? styles.error : styles.content} className={status === "failed" || status === "expired" ? styles.error : styles.content}
@@ -183,41 +170,6 @@ function RegionalQrPaymentDialog({
); );
} }
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
) {
if (experience === "gcashQrPh") {
return {
paymentName: "GCash / QR Ph",
title: "Pay with GCash / QR Ph",
description:
"Open GCash or another QR Ph-compatible app and scan this code.",
qrTitle: "GCash / QR Ph payment QR code",
unavailableMessage:
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
};
}
if (experience === "qris") {
return {
paymentName: "QRIS",
title: "Scan to pay with QRIS",
description:
"Open a QRIS-compatible banking or wallet app and scan this code.",
qrTitle: "QRIS payment QR code",
unavailableMessage:
"QRIS data is unavailable. Please close this dialog and try again.",
};
}
return {
paymentName: "payment QR",
title: "Scan to pay",
description: "Open a compatible banking or wallet app and scan this code.",
qrTitle: "Payment QR code",
unavailableMessage:
"Payment QR data is unavailable. Please close this dialog and try again.",
};
}
interface EzpayRedirectConfirmDialogProps { interface EzpayRedirectConfirmDialogProps {
description: ReactNode; description: ReactNode;
externalCheckoutAnalyticsKey: string; externalCheckoutAnalyticsKey: string;
+55 -78
View File
@@ -4,7 +4,6 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import { import {
getPaymentUrl, getPaymentUrl,
getPaymentUrlHostname,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
@@ -42,31 +41,31 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null; giftCategory?: string | null;
giftPlanId?: string | null; giftPlanId?: string | null;
countryCode?: string | null;
} }
export interface PaymentLaunchFlow { export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null; ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void; handleEzpayCancel: () => void;
handleEzpayConfirm: () => void; handleEzpayConfirm: () => void;
handleRegionalQrClose: () => void; handleQrisClose: () => void;
handleQrisResume: () => void;
handleStripeClose: () => void; handleStripeClose: () => void;
handleStripeConfirmed: () => void; handleStripeConfirmed: () => void;
hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean; isConfirmingEzpay: boolean;
regionalQrErrorMessage: string | null; qrisErrorMessage: string | null;
regionalQrPayment: RegionalQrPaymentDetails | null; qrisPayment: QrisPaymentDetails | null;
regionalQrStatus: PaymentContextState["orderStatus"]; qrisStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void; resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean; shouldShowEzpayConfirmDialog: boolean;
shouldShowRegionalQrDialog: boolean; shouldShowQrisDialog: boolean;
shouldShowStripeDialog: boolean; shouldShowStripeDialog: boolean;
stripeClientSecret: string | null; stripeClientSecret: string | null;
} }
export interface RegionalQrPaymentDetails { export interface QrisPaymentDetails {
amountCents: number; amountCents: number;
currency: string; currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string; orderId: string;
qrData: string; qrData: string;
} }
@@ -156,36 +155,30 @@ export function usePaymentLaunchFlow({
subscriptionType, subscriptionType,
giftCategory, giftCategory,
giftPlanId, giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { }: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null string | null
>(null); >(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
null,
);
const stripeClientSecret = payment.payParams const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams) ? getStripeClientSecret(payment.payParams)
: null; : null;
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const paymentCurrency = payment.payParams
? paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
null
: selectedPlan?.currency ?? null;
const ezpayLaunchTarget = const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams) payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams, { ? resolveEzpayLaunchTarget(payment.payParams)
countryCode,
currency: paymentCurrency,
})
: null; : null;
const ezpayPaymentUrl = const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url" ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl ? ezpayLaunchTarget.paymentUrl
: null; : null;
const regionalQrPayment: RegionalQrPaymentDetails | null = const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const qrisPayment: QrisPaymentDetails | null =
payment.payParams && payment.payParams &&
payment.currentOrderId && payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr" ezpayLaunchTarget?.kind === "qr"
@@ -195,9 +188,9 @@ export function usePaymentLaunchFlow({
selectedPlan?.amountCents ?? selectedPlan?.amountCents ??
0, 0,
currency: currency:
paymentCurrency ?? paymentParamString(payment.payParams, "currency") ??
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"), selectedPlan?.currency ??
experience: ezpayLaunchTarget.experience, "IDR",
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData, qrData: ezpayLaunchTarget.qrData,
} }
@@ -216,33 +209,7 @@ export function usePaymentLaunchFlow({
const isEzpay = isEzpayPayment(payment.payParams); const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) { if (isEzpay) {
const target = resolveEzpayLaunchTarget(payment.payParams, { const target = resolveEzpayLaunchTarget(payment.payParams);
countryCode,
currency: paymentCurrency,
});
const channelType = paymentParamString(
payment.payParams,
"channelType",
"channel_type",
);
const channelCode = paymentParamString(
payment.payParams,
"channelCode",
"channel_code",
);
const namedPaymentUrl = getPaymentUrl(payment.payParams);
log.debug(`[${logScope}] ezpay launch target resolved`, {
countryCode: countryCode?.trim().toUpperCase() ?? null,
currency: paymentCurrency?.trim().toUpperCase() ?? null,
channelType: channelType?.toUpperCase() ?? null,
channelCode,
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
hasQrData: target.kind === "qr",
paymentUrlHost:
target.kind === "url"
? getPaymentUrlHostname(target.paymentUrl)
: null,
});
if (target.kind === "error") { if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url"); trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({ paymentDispatch({
@@ -257,18 +224,11 @@ export function usePaymentLaunchFlow({
trackPaymentCheckoutFailed(payment, "missing_checkout_url"); trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({ paymentDispatch({
type: "PaymentLaunchFailed", type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing payment QR code.", errorMessage: "Missing order id before showing QRIS.",
}); });
return; return;
} }
trackPaymentCheckoutOpened( trackPaymentCheckoutOpened(payment, "qris_embedded");
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
return; return;
} }
@@ -276,7 +236,7 @@ export function usePaymentLaunchFlow({
if (!AppEnvUtil.isProduction()) { if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, { log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrlHost: getPaymentUrlHostname(paymentUrl), paymentUrl,
subscriptionType, subscriptionType,
}); });
return; return;
@@ -302,7 +262,7 @@ export function usePaymentLaunchFlow({
const paymentUrl = getPaymentUrl(payment.payParams); const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) { if (paymentUrl) {
try { try {
window.location.assign(paymentUrl); window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl); trackPaymentCheckoutOpened(payment, paymentUrl);
} catch { } catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -323,7 +283,6 @@ export function usePaymentLaunchFlow({
log, log,
logScope, logScope,
characterSlug, characterSlug,
countryCode,
payment.currentOrderId, payment.currentOrderId,
payment, payment,
payment.launchNonce, payment.launchNonce,
@@ -332,7 +291,6 @@ export function usePaymentLaunchFlow({
payment.plans, payment.plans,
payment.selectedPlanId, payment.selectedPlanId,
paymentDispatch, paymentDispatch,
paymentCurrency,
returnTo, returnTo,
subscriptionType, subscriptionType,
giftCategory, giftCategory,
@@ -349,13 +307,22 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams, payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl, paymentUrl: ezpayPaymentUrl,
}); });
const shouldShowRegionalQrDialog = Boolean( const shouldShowQrisDialog = Boolean(
regionalQrPayment && !payment.isPaid, qrisPayment &&
qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid,
);
const hasHiddenQrisPayment = Boolean(
qrisPayment &&
qrisPayment.orderId === hiddenQrisOrderId &&
payment.isPollingOrder &&
!payment.isPaid,
); );
function resetLaunchState(): void { function resetLaunchState(): void {
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null); setHiddenStripeClientSecret(null);
setHiddenQrisOrderId(null);
} }
function handleStripeClose(): void { function handleStripeClose(): void {
@@ -402,29 +369,39 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
} }
function handleRegionalQrClose(): void { function handleQrisClose(): void {
log.debug(`[${logScope}] regional payment QR dialog closed`, { log.debug(`[${logScope}] qris dialog closed`, {
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId, orderId: qrisPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
subscriptionType, subscriptionType,
}); });
paymentDispatch({ type: "PaymentReset" }); setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
}
function handleQrisResume(): void {
if (!hasHiddenQrisPayment) return;
log.debug(`[${logScope}] qris dialog resumed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
setHiddenQrisOrderId(null);
} }
return { return {
ezpayPaymentUrl, ezpayPaymentUrl,
handleEzpayCancel, handleEzpayCancel,
handleEzpayConfirm, handleEzpayConfirm,
handleRegionalQrClose, handleQrisClose,
handleQrisResume,
handleStripeClose, handleStripeClose,
handleStripeConfirmed, handleStripeConfirmed,
hasHiddenQrisPayment,
isConfirmingEzpay, isConfirmingEzpay,
regionalQrErrorMessage: payment.errorMessage, qrisErrorMessage: payment.errorMessage,
regionalQrPayment, qrisPayment,
regionalQrStatus: payment.orderStatus, qrisStatus: payment.orderStatus,
resetLaunchState, resetLaunchState,
shouldShowEzpayConfirmDialog, shouldShowEzpayConfirmDialog,
shouldShowRegionalQrDialog, shouldShowQrisDialog,
shouldShowStripeDialog, shouldShowStripeDialog,
stripeClientSecret, stripeClientSecret,
}; };
@@ -24,7 +24,6 @@ export interface SubscriptionCheckoutButtonProps {
subscriptionType: "vip" | "topup"; subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo; returnTo?: SubscriptionReturnTo;
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
countryCode?: string | null;
} }
export function SubscriptionCheckoutButton({ export function SubscriptionCheckoutButton({
@@ -32,7 +31,6 @@ export function SubscriptionCheckoutButton({
subscriptionType, subscriptionType,
returnTo = null, returnTo = null,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
countryCode = null,
}: SubscriptionCheckoutButtonProps) { }: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
@@ -44,18 +42,25 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined, returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug, characterSlug: sourceCharacterSlug,
subscriptionType, subscriptionType,
countryCode,
}); });
const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const readyLabel = "Pay and Top Up"; const readyLabel = "Pay and Top Up";
const label = payment.isPollingOrder const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
: readyLabel; : readyLabel;
const handleClick = () => { const handleClick = () => {
if (isResumingQris) {
paymentLaunch.handleQrisResume();
return;
}
if (disabled || isLoading) return; if (disabled || isLoading) return;
paymentLaunch.resetLaunchState(); paymentLaunch.resetLaunchState();
paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
@@ -66,7 +71,7 @@ export function SubscriptionCheckoutButton({
type="button" type="button"
data-analytics-key="subscription.checkout" data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout" data-analytics-label="Start subscription checkout"
disabled={disabled} disabled={disabled && !isResumingQris}
isLoading={isLoading} isLoading={isLoading}
onClick={handleClick} onClick={handleClick}
> >
@@ -337,7 +337,6 @@ export function SubscriptionScreen({
subscriptionType={subscriptionType} subscriptionType={subscriptionType}
returnTo={returnTo} returnTo={returnTo}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG} sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
countryCode={countryCode}
/> />
</div> </div>
+8 -7
View File
@@ -19,7 +19,6 @@ export interface TipCheckoutButtonProps {
disabled?: boolean; disabled?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string; returnPath: string;
countryCode?: string | null;
} }
export function TipCheckoutButton({ export function TipCheckoutButton({
@@ -28,7 +27,6 @@ export function TipCheckoutButton({
disabled = false, disabled = false,
onOrder, onOrder,
returnPath, returnPath,
countryCode = null,
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
const character = useActiveCharacter(); const character = useActiveCharacter();
const payment = usePaymentState(); const payment = usePaymentState();
@@ -42,11 +40,14 @@ export function TipCheckoutButton({
giftCategory, giftCategory,
giftPlanId, giftPlanId,
characterSlug: character.slug, characterSlug: character.slug,
countryCode,
}); });
const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const label = payment.isPollingOrder const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
@@ -61,8 +62,8 @@ export function TipCheckoutButton({
data-analytics-key="tip.checkout" data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip" data-analytics-label="Buy coffee tip"
className={styles.checkoutButton} className={styles.checkoutButton}
disabled={disabled || isLoading} disabled={(disabled && !isResumingQris) || isLoading}
onClick={onOrder} onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
> >
{label} {label}
</button> </button>
-1
View File
@@ -305,7 +305,6 @@ export function TipScreen({
disabled={!canCreateOrder} disabled={!canCreateOrder}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath} returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/> />
</div> </div>
</> </>
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import { import {
getPaymentUrl, getPaymentUrl,
getPaymentUrlHostname,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
@@ -44,103 +43,36 @@ describe("payment launch helpers", () => {
).toBeNull(); ).toBeNull();
}); });
it("prioritizes a hosted GCash URL for Philippine checkout", () => { it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
expect( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget({
provider: "ezpay", provider: "ezpay",
channelType: "URL", channelType: "URL",
payData: "https://pay.example/gcash", payData: "https://pay.example/qris",
}, { countryCode: "PH", currency: "PHP" }), }),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" }); ).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
expect( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "000201010212ph-qr-payload",
cashierUrl: "https://pay.example/gcash-hosted",
}, { countryCode: "PH" }),
).toEqual({
kind: "url",
paymentUrl: "https://pay.example/gcash-hosted",
});
});
it("keeps a usable QR Ph fallback without calling it QRIS", () => {
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay",
channelType: "QR",
payData: "000201010212ph-qr-payload",
},
{ currency: "PHP" },
),
).toEqual({
kind: "qr",
experience: "gcashQrPh",
qrData: "000201010212ph-qr-payload",
});
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay",
payData: "000201010212ph-qr-payload",
},
{ countryCode: "PH" },
),
).toEqual({
kind: "qr",
experience: "gcashQrPh",
qrData: "000201010212ph-qr-payload",
});
});
it("keeps Indonesian QRIS ahead of an optional hosted URL", () => {
expect(
resolveEzpayLaunchTarget(
{
provider: "ezpay", provider: "ezpay",
channelType: "QR", channelType: "QR",
payData: "00020101021226670016COM.NOBUBANK.WWW", payData: "00020101021226670016COM.NOBUBANK.WWW",
cashierUrl: "https://pay.example/indonesia", }),
},
{ countryCode: "ID", currency: "IDR" },
),
).toEqual({ ).toEqual({
kind: "qr", kind: "qr",
experience: "qris",
qrData: "00020101021226670016COM.NOBUBANK.WWW", qrData: "00020101021226670016COM.NOBUBANK.WWW",
}); });
});
it("reports a regional error only when both URL and QR data are absent", () => {
expect( expect(
resolveEzpayLaunchTarget( resolveEzpayLaunchTarget({
{
provider: "ezpay", provider: "ezpay",
channelType: "QR", channelType: "QR",
payData: "", payData: "",
}, }),
{ countryCode: "PH" },
),
).toEqual({ ).toEqual({
kind: "error", kind: "error",
errorMessage: errorMessage: "QRIS payment data is missing. Please try again.",
"GCash / QR Ph payment data is missing. Please try again.",
}); });
}); });
it("keeps diagnostics to the payment URL hostname", () => {
expect(
getPaymentUrlHostname(
"https://pay.example/gcash?token=must-not-appear-in-logs",
),
).toBe("pay.example");
expect(getPaymentUrlHostname("not-a-url")).toBeNull();
});
it("rejects VA and unknown Ezpay channel types with explicit errors", () => { it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
expect( expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }), resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
+13 -102
View File
@@ -13,20 +13,9 @@ const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget = export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string } | { kind: "url"; paymentUrl: string }
| { | { kind: "qr"; qrData: string }
kind: "qr";
experience: EzpayQrExperience;
qrData: string;
}
| { kind: "error"; errorMessage: string }; | { kind: "error"; errorMessage: string };
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
export interface ResolveEzpayLaunchContext {
countryCode?: string | null;
currency?: string | null;
}
function getNonEmptyString( function getNonEmptyString(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
...keys: string[] ...keys: string[]
@@ -52,12 +41,6 @@ function getHttpPaymentUrl(value: string | null): string | null {
} }
} }
export function getPaymentUrlHostname(value: string | null): string | null {
const paymentUrl = getHttpPaymentUrl(value);
if (!paymentUrl) return null;
return new URL(paymentUrl).hostname;
}
export function getPaymentUrl(payParams: Record<string, unknown>): string | null { export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [ const keys = [
"cashierUrl", "cashierUrl",
@@ -88,7 +71,6 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
export function resolveEzpayLaunchTarget( export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget { ): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString( const rawChannelType = getNonEmptyString(
payParams, payParams,
@@ -97,33 +79,13 @@ export function resolveEzpayLaunchTarget(
); );
const channelType = rawChannelType?.toUpperCase() ?? null; const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data"); const payData = getNonEmptyString(payParams, "payData", "pay_data");
const payDataUrl = getHttpPaymentUrl(payData);
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
const paymentUrl = payDataUrl ?? namedPaymentUrl;
const qrData = payData && !payDataUrl ? payData : null;
const region = resolveEzpayRegion(payParams, context);
if (
region === "ph" &&
paymentUrl &&
(channelType === "QR" || channelType === "URL" || channelType === null)
) {
return { kind: "url", paymentUrl };
}
if (channelType === "QR") { if (channelType === "QR") {
if (qrData) { return payData
return { ? { kind: "qr", qrData: payData }
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
: { : {
kind: "error", kind: "error",
errorMessage: regionalQrMissingMessage(region), errorMessage: "QRIS payment data is missing. Please try again.",
}; };
} }
@@ -135,6 +97,8 @@ export function resolveEzpayLaunchTarget(
} }
if (channelType === "URL") { if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl return paymentUrl
? { kind: "url", paymentUrl } ? { kind: "url", paymentUrl }
: { : {
@@ -150,68 +114,16 @@ export function resolveEzpayLaunchTarget(
}; };
} }
if (paymentUrl) return { kind: "url", paymentUrl }; const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
if (qrData && region !== null) { return legacyPaymentUrl
return { ? { kind: "url", paymentUrl: legacyPaymentUrl }
kind: "qr", : {
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return {
kind: "error", kind: "error",
errorMessage: errorMessage:
"Ezpay payment parameters did not include a supported URL or payment QR code.", "Ezpay payment parameters did not include a supported URL or QRIS code.",
}; };
} }
function resolveEzpayRegion(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext,
): "ph" | "id" | null {
const currency = (
context.currency ?? getNonEmptyString(payParams, "currency")
)
?.trim()
.toUpperCase();
if (currency === "PHP") return "ph";
if (currency === "IDR") return "id";
const countryCode = (
context.countryCode ??
getNonEmptyString(
payParams,
"purchaseCountryCode",
"purchase_country_code",
"countryCode",
"country_code",
)
)
?.trim()
.toUpperCase();
if (countryCode === "PH") return "ph";
if (countryCode === "ID") return "id";
return null;
}
function resolveEzpayQrExperience(
region: "ph" | "id" | null,
): EzpayQrExperience {
if (region === "ph") return "gcashQrPh";
if (region === "id") return "qris";
return "paymentQr";
}
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
if (region === "ph") {
return "GCash / QR Ph payment data is missing. Please try again.";
}
if (region === "id") {
return "QRIS payment data is missing. Please try again.";
}
return "Payment QR data is missing. Please try again.";
}
export function getStripeClientSecret( export function getStripeClientSecret(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
): string | null { ): string | null {
@@ -254,19 +166,18 @@ export async function launchEzpayRedirect({
onOpened, onOpened,
onFailed, onFailed,
}: LaunchEzpayRedirectInput): Promise<void> { }: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", { log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId), hasOrderId: Boolean(orderId),
orderId, orderId,
subscriptionType, subscriptionType,
paymentUrlHost, paymentUrl,
}); });
if (!orderId) { if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay."; const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", { log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType, subscriptionType,
paymentUrlHost, paymentUrl,
errorMessage, errorMessage,
}); });
onFailed(errorMessage); onFailed(errorMessage);