Shopark license reconciliation
APay owns accounts and subscriptions. Shopark's Saas application links the merchant and applies the current license to stores through Go RPC. Core payment and subscription APIs remain the billing authority.
Plan catalog and license snapshots
The partner contract in Shopark plugins/app/Saas/PARTNER_APAY.md governs external licenses; native Shopark billing rules do not replace APay billing. app/extensions/shopark/shared/plans.ts is the APay mapping source, re-exported by app/themes/shopark/data/plans.ts. Theme and management extension share the signed client, entitlement resolver, durable versions, readiness checks and reconciliation storage; admin re-pushes cannot bypass explicit plan keys. Public sales include seven price options, all in CNY:
| planKey | Display name | Month | Year | planCode |
|---|---|---|---|---|
| free | Unpaid / expired | Not sold | Not sold | free |
| showcase | Showcase | Unavailable | 399 CNY/year | showcase-yearly |
| starter | Starter | 199 CNY/month | 1,999 CNY/year | starter-monthly / starter-yearly |
| professional | Professional | 299 CNY/month | 2,999 CNY/year | professional-monthly / professional-yearly |
| premium | Premium | 599 CNY/month | 5,999 CNY/year | premium-monthly / premium-yearly |
| enterprise | Enterprise Custom | Contract | Contract | enterprise-custom (explicit enterprise key) |
Subscription product metadata declares shopark_plan_key, shopark_plan_code, shopark_store_limit, level, and subscription_cycle. The canonical ranks are 0–5 in table order. Existing known codes remain readable: bare enterprise and enterprise-monthly/yearly mean premium, while explicit shopark_plan_key=enterprise always means Enterprise Custom. Unknown keys/codes are excluded; do not infer Enterprise Custom from a display name. Free and Enterprise Custom are never listed as public fixed-price products. The public catalog also excludes conflicting prices, cycles, ranks or locale price overrides.
Every outgoing license contains { planCode, planKey, expireAt, maxStores, version }. GET /api/shopark/entitlement now includes data.planKey; it computes only active, already-started subscription periods. Refund/revocation, effective cancellation and expiry fall back to free; cancellation at period end retains paid rights until that date. expireAt=null means long-term validity (Shopark stores 2099-12-31T23:59:59Z), not a trial. The native 14-day Professional trial is unavailable through APay partner snapshots.
Versions use durable per-user compare-and-swap settings shopark_license_version.<userId>, allocated before reading subscriptions. They increase across simultaneous pushes, process restarts and clock rollback. These settings are private and must survive releases and rollbacks. They require no core schema migration.
Entitlements apply to all stores owned by the APay user. maxStores is a creation limit, separate from per-store resources. Store creation and capacity displays use Shopark's returned quota, including platform caps and pending creation reservations. An uncertain creation retry retains its original request ID and frozen parameters, including when reopening the dialog. replayed=true refers to the same store; licenseApplied=false requires pending-sync messaging. store/list additionally returns per-store operating metrics (stats: total/active products, 30d/total paid orders, 30d/total GMV, buyers, 30d UV/PV, lastOrderAt) and a merchant-wide summary (including opened region/timezone and currency distribution), while store/overview provides the platform-wide store footprint and operating breakdown for /admin/plugins/shopark/stores.
| planKey | Products / active products | Content pages | Domains | Staff (excluding owner) | Storage | AI points/month |
|---|---|---|---|---|---|---|
| showcase | 20 | 5 | 1 | 0 | 200 MB | 0 |
| starter | 500 | Uncapped | 1 | 2 | 5 GB | 100 |
| professional | 5,000 | Uncapped | 3 | 5 | 20 GB | 300 |
| premium | 20,000 | Uncapped | 10 | 15 | 100 GB | 1,000 |
| enterprise | Current Premium baseline; contract overrides are not implemented |
Storage is decimal (1 GB = 1,000,000,000 bytes). Shopark meters resources and AI; APay does not duplicate metering. AI points are granted monthly even for annual subscriptions, do not roll over, and upgrades only add the current-month difference without clearing used points. Known gap: APay partner AI pools refresh by UTC calendar month because the partner contract has no billing-period anchor. Do not promise billing-anniversary grants. Shopark has no partner resource-usage endpoint, so APay cannot show real usage before a downgrade.
Existing-store binding and downgrades
Binding uses challenge → verify → confirm. Store rows show localized names from planKey (known edition aliases only as fallback). Verify shows each store's currentPlanKey/currentExpireAt → nextPlanKey/nextExpireAt. Any row with downgrade=true requires an unchecked acknowledgement; confirm sends acknowledgeDowngrade=true only after the user checks it. bind:acknowledge_required remains a visible business error. failed[] produces “Pending sync”, independently of account linking, and reconciliation repairs the license.
Binding stops renewal of the original Shopark subscription and switches billing to APay. A lower partner snapshot takes effect immediately: existing excess data stays, but new products, uploads, staff, domains and AI use may be blocked. APay preserves its core rule that lower-plan purchases are blocked during an active subscription; the theme explains this before attempting checkout. This release does not add prorated billing or a scheduled downgrade purchase flow.
GET /api/shopark/cron/license-sync requires Authorization: Bearer $CRON_SECRET in every environment. It returns { data: { busy, checked, pushed, failed, pending, cursor, lastFullScan } }; a concurrent lease holder returns only { data: { busy: true } }. Missing authentication returns 401, missing partner configuration returns 503, and outstanding retries return 503 with the batch report. Storage or transport failures remain errors.
The task scans users by ascending ID, keeps a durable cursor, retries failed users and restarts the sweep when it reaches the end. A batch scans up to 100 users and retries up to 25 users within a 45 second processing budget. Paid users can recover failed initial provisioning; free users only synchronize existing Shopark accounts. Each push recomputes the complete current subscription state, including refunds, cancellations and expiry. There is no three day cutoff. Shopark reports unfinished creation as pendingRequests, which also enters the retry queue. State is stored in the private shopark_license_reconcile_state setting; never register it as public.
Configure the core scheduler with path /api/shopark/cron/license-sync, method GET, schedule 5 (minutes), useCronSecret=true, and enabled=true. Serverless deployments use an external scheduler with the same header. The full sweep duration depends on user count, latency and frequency. Verify a completed sweep and zero pending users; an old successful HTTP response is insufficient.
Persist a minute interval as a JSON number (5), not a string ("5"); an invalid interval falls back to daily. For loopback development with Shopark tenant paths enabled and local IP access allowed, use SHOPARK_PARTNER_API_URL=http://127.0.0.1:5490/1/app/saas/partner/apay when the configured super store is ID 1. This supplies tenant routing without a temporary request header. Restart the local APay process to reload environment variables; production uses its configured super-store domain.
Only license/sync retries an explicit HTTP 429, up to three attempts within the existing 25-second request deadline. Each attempt signs a fresh nonce while retaining the same complete snapshot and version. Retry-After seconds are bounded to five seconds; other values use a one-second delay. Network uncertainty, business rejection, store creation and binding are not automatically replayed. Exhausted attempts and unfinished creation remain failures in the durable queue.
Store creation freezes its parameters before calling Shopark. Retry uncertain results with the same requestId; after ten minutes an interrupted request can recover the existing store by domain and its random administrator identity. Background reconciliation also resumes the request if the browser loses its ID. Unfinished creation reserves capacity and cannot be bypassed with another request. licenseApplied=false means the store exists while authorization is pending. Linking an existing merchant likewise reports failed[] independently of successful account linking.
Deploy Go first and migrate the store's license_source, license_version and 64 character edition. In Shopark's plugins/ directory run php shopark db:sync Saas to add the request recovery column, then deploy PHP and APay. Reconcile existing accounts before reopening purchases. Keep the additive columns on rollback; do not revert the Go version fence or restart local Shopark billing for accounts still managed by APay.
Before launch, verify partner reachability, the active Shopark theme, sellable tagged subscription products, platform store limits, a completed reconciliation sweep, and real purchase/refund/expiry/linking flows. Builds and isolated regression tests do not establish production connectivity.
Rollout and rollback
- Confirm Shopark Go migration,
php shopark db:sync Saas, and PHP deployment support explicit plan keys. Inspect the actual deployed version; a local commit is not production evidence. - Review APay's existing product IDs, prices, currency, metadata and references. Generate the PostgreSQL seed with
node --experimental-strip-types app/themes/shopark/scripts/generate-plan-seed.mjs; it inserts missing products only, never deletes products or rewrites existing contracts. It assumes a CNY base-currency deployment. Review conflicting existing rows separately; retain IDs and existing subscription amounts/periods. Do not execute this SQL as an automatic deployment migration. - Publish APay after review. Run authenticated license-sync batches until
lastFullScanadvances after the release andpending=0;503orfailed[]/pendingRequests>0is unfinished work. Keep the cron running. - With real accounts, verify purchase, upgrade, blocked active-period downgrade, refund, expiry and existing-store binding with and without downgrade acknowledgement. After expiry, verify purchase of a lower plan. Confirm currency and charge amounts before claiming payment verification.
On failure, pause new sales and restore the reviewed APay artifact/product configuration without deleting orders, subscriptions, mapping data, reconciliation state or version counters. Keep Shopark's additive schema and external billing fence. Rollback code must retain explicit planKey and durable versions; reverting to a client that omits the Enterprise Custom key is unsafe. To restore an entitlement, recompute the intended APay state and send a newer snapshot, never replay an old version. Product changes and real-account acceptance require a separate reviewed operation; this development task does not execute production SQL, charge money or publish.
Regression: node app/themes/shopark/scripts/check-plans.mjs (also a Shopark build check). After npm run build, run node --experimental-strip-types app/themes/shopark/scripts/check-reconcile-artifact.mjs against its temporary SQLite database and signed local partner fixture. The fixture covers partial failure 503, pending creation, restart recovery, monotonic versions, six keys, revocation/expiry, concurrency and private-state isolation. With the Shopark source and installed PHP dependencies, php app/themes/shopark/scripts/check-partner-auth.php /path/to/shopark/plugins runs its actual middleware against isolated in-memory RPC/Redis: shared HMAC vector, wrong signature, timestamp expiry, replayed nonce and fail-closed storage. It does not start Shopark or access its database. Run smoke-partner.mjs separately against a configured real Shopark for deployment verification; local contract coverage does not prove live connectivity.