Changelog
2026.05.18 — Discount Codes: full promo system live
A first-class discount code system is now available for all client orders.
New: Validate endpoint (client, unauthenticated-optional)
GET /api/v1/discounts/validate?code=SAVE20&subtotal=500Returns discount_amount and final_total without consuming the code.
Intended for checkout forms to give instant feedback.
Updated: POST /api/v1/orders accepts discount_code
{ "items": […], "payment_method": "bkash", "discount_code": "SAVE20" }The code is re-validated and atomically consumed inside the order transaction.
The order response now includes discount_code and discount_amount fields
and total_amount reflects the post-discount price.
Admin CRUD for discount codes
Full create / read / update / delete surface at /admin/discounts plus
GET /admin/discounts/:id/orders to inspect which orders used a specific code.
Delete is blocked if the code has already been applied to at least one
order — set is_active = false to stop further use while keeping history.
See Discount Codes for the complete integration guide.
2026.05.18 — Redemption: phone required, email delivers voucher
Changed the redemption contract (POST /api/v1/redemption/redeem) to a
simpler, fixed set of inputs:
| Field | Type | Detail |
|---|---|---|
code | string | Required. The 9-char token from the QR / URL. |
phone | string | Required. Recipient msisdn — captured as a lead, used by support. The alias msisdn is also accepted. |
email | string (email) | Optional. When provided, the revealed voucher code + instructions are emailed to the address after a successful redemption (fire-and-forget). |
Removed the require_email/require_phone per-campaign flags from the
redeem endpoint — phone is unconditionally required and email delivery
is unconditionally optional. The lookup requires field now always
returns { phone: true, email: false } so the storefront form stays in
sync without any server-side configuration.
No authentication is required for either client endpoint (lookup or
redeem).
See Promo Campaigns for the full flow.
2026.05.18 — Simplified fulfillment-fields shape & shorter doc routes
Two cleanups motivated by the new fulfillment_config.fields[] shape:
fulfillment_config is now a single shape
The legacy required_fields, optional_fields, field_labels and
field_types arrays have been removed from every public API surface.
fields[] is now the only shape — each entry already carries the
name, label, type and required flag, making the legacy keys
redundant.
"fulfillment_input": { "mode": "CODE", "fields": [ { "name": "player_uid", "label": "Player UID", "type": "text", "required": true } ], "required_fields": ["player_uid"], "optional_fields": [], "field_labels": { "player_uid": "Player UID" }, "field_types": { "player_uid": "text" }, "identifier_field": null}Affected endpoints: GET /api/v1/products/:id (response), POST/PUT /admin/products[/:id] (request DTO), and GET /admin/orders/variants/:id/fulfillment-fields (response). The
normaliser still reads legacy storage rows (so saved
fulfillment_config rows with the old shape keep working) but never
emits them.
Shorter developer-portal routes
Every doc page has moved to a flat, single-segment URL. The legacy
nested paths under /getting-started/ and /guides/ no longer exist.
| Was | Is now |
|---|---|
/getting-started/quickstart/ | /quickstart/ |
/getting-started/authentication/ | /auth/ |
/getting-started/envelope/ | /envelope/ |
/guides/orders/ | /orders/ |
/guides/checkout-flow/ | /checkout/ |
/guides/identifier-fields/ | /identifiers/ |
/guides/inventory-signals/ | /inventory/ |
/guides/payments/ | /payments/ |
/guides/voucher-redemption/ | /vouchers/ |
/guides/redemption/ | /redemption/ |
/guides/sorting-and-curation/ | /sorting/ |
/guides/webhooks/ | /webhooks/ |
/guides/error-handling/ | /error-handling/ |
/guides/admin-product-management/ | /admin-products/ |
/guides/fulfillment-recovery/ | /recovery/ |
/guides/sweepers/ | /sweepers/ |
Bookmark the new URLs.
2026.05.17 — Per-product configurable fulfillment fields
Products can now declare arbitrary recipient inputs via a richer shape on
fulfillment_config.fields[], with name, label, type and required
per entry. Game cards need a Player UID and Zone ID; gift cards need an
email; mobile top-ups need nothing extra — all without code changes.
{ "fields": [ { "name": "player_uid", "label": "Player UID", "type": "text", "required": true }, { "name": "zone_id", "label": "Zone ID", "type": "text", "required": false } ]}- Admin write (
POST/PUT /admin/products[/:id]) acceptsfulfillment_config.fields[]— an array ofFulfillmentFieldDto. - Client read (
GET /api/v1/products/:id) returns the same shape underfulfillment_inputso the storefront can render the right inputs. - Order validation (
POST /api/v1/orders) honours the rich shape for required-field enforcement. - Variant fulfillment-fields endpoint
(
GET /admin/orders/variants/:id/fulfillment-fields) returnsfields[], merging partner-side declared keys (e.g. ZenditrequiredFields) as required entries. - Single-identifier rule still applies: at most one of
email,player_id,account_id,uidacross all entries — enforced at admin ingest time and again at the read path.
See the Admin Product Management guide for the full schema.
2026.05.17 — Redemption campaigns (QR promo codes)
A brand-new domain for distributing pre-funded voucher codes (Spotify,
Netflix, Free Fire, …) via printed/shareable QR cards. The QR points to
https://bdvoucher.com/redeem/<TOKEN>; scanning reveals the real voucher
code + redemption instructions on the storefront and captures an
email/phone lead in the process.
New DB tables (production schema)
redemption_campaigns— group of N codes; holds headline, CTA, default instructions,require_email/require_phone,max_per_ip, start/end window, optionalvariant_idfor analytics.redemption_codes— unique 9-char token, theactual_voucher_coderevealed on redemption, per-code instructions override, status lifecyclepending_stock → unused → redeemed | voided | expired.redemption_attempts— append-only audit log of every lookup/redeem.
Migration: prisma/migrations/redemption_module.sql (also seeds the
Spotify Premium $10 — Promo campaign with the 10 sample tokens).
New client API endpoints (public, anonymous)
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/redemption/lookup/:code | Returns campaign info + required form fields; does not consume the code. |
POST | /api/v1/redemption/redeem | Atomically consumes the code (SELECT … FOR UPDATE inside $transaction) and reveals voucher_code + instructions. |
Same-IP 24-hour recovery is built in so a closed-tab user can re-see their voucher.
New admin API endpoints (/admin/redemption)
- Campaign CRUD —
GET/POST/PUT/DELETE /campaigns[/:id]+GET /campaigns/:id/stats. - Codes —
POST /campaigns/:id/codes/generate— bulk-create tokens. Two modes: passentries[]with real codes (statusunused), orcountonly (statuspending_stock, fill in real codes later).GET /campaigns/:id/codes— paginated, filterable.GET /campaigns/:id/codes/export?base_url=…— CSV with the scan URL ready for the print shop.POST /campaigns/:id/codes/void— bulk void unused codes.PUT /codes/:id— set/editactual_voucher_code,instructions, orstatus. Auto-flipspending_stock → unusedthe moment a real code is supplied.DELETE /codes/:id— blocked once redeemed.
OpenAPI
Client now exposes 91 ops (was 89), admin exposes 236 ops (was
224). Specs regenerated and synced to docs-site/public/{client,admin}/.
See the new guide: Redemption campaigns (QR promo).
Zendit purchase scripts
For campaigns whose actual voucher codes come from a partner API, the
new scripts/lib/zendit-redemption-import.ts helper buys a batch of N
vouchers and splits them between a redemption campaign (auto-generated
tokens or backfilling existing pending_stock rows) and the regular
preloaded pool. Two ready-to-run entry points:
npm run zendit:import:spotify-redemption # SPOTIFY_CO_1M_WHSnpm run zendit:import:netflix-redemption # NETFLIX_US_20_WHS, 10 redeem + 5 preloadedThe Netflix import has already run in production: 10 new tokens under
the netflix-20-promo campaign + 5 preloaded vouchers attached to
variant 22406 (ZT-NETFLIX_US_20_WHS).
2026.05.14 — Curation, sort & variant ordering
Unified sort_order across every curated resource
Every customer-facing list on the storefront is now admin-curated by default.
The sort_order INTEGER column + idx_<table>_sort_order index is wired up on:
brands,categories,tags,product_typesproducts,product_variantspayment_methods,faqshomepage_banners,homepage_sections,homepage_section_items
Default ordering on every list endpoint is now sort_order ASC, id ASC so
pagination stays stable even when many rows share the same sort_order.
New: product_variants.sort_order
Variants now carry the same column so operators can drag-reorder the variant
chips on a product page. Migration: prisma/migrations/product_variants_sort_order.sql.
Admin reorder API — one canonical contract
A single ReorderDto = { items: [{ id, sort_order }] } payload (1–500 items,
no duplicate ids, atomic Prisma $transaction) is accepted by:
| Method | Path |
|---|---|
PATCH | /admin/brands/reorder |
PATCH | /admin/categories/reorder |
PATCH | /admin/tags/reorder |
PATCH | /admin/product-types/reorder |
PATCH | /admin/payment-methods/reorder |
PATCH | /admin/products/reorder |
PATCH | /admin/products/:productId/variants/reorder |
POST | /admin/homepage/banners/reorder |
POST | /admin/homepage/sections/reorder |
POST | /admin/homepage/sections/:sectionId/items/reorder |
Unknown ids return 404; duplicates / empty / >500 items return 400.
Client API — ?variant_sort= on products
GET /products and GET /products/:id accept a new optional query param to
control variant ordering inside each product’s variants[] array:
variant_sort | Order applied |
|---|---|
price (default) | price ASC, sort_order ASC, id ASC — cheapest first |
sort | sort_order ASC, id ASC — admin-curated |
name | name ASC, id ASC — alphabetical |
Homepage and wishlist endpoints already used price-asc for variants; this aligns the main products endpoint with the same UX and adds the explicit override.
See the new Sorting & curation guide for the full contract.
2026.04.27 — Multi-code dispatch & consolidated email
Consolidated voucher email — one email per order
Previously, when a customer bought multiple products or multiple quantities of the same product, each order item triggered its own separate email. Customers received a cluttered inbox with several messages for a single checkout.
New behaviour: every fulfilled order sends exactly one email containing all voucher codes across every item and every quantity unit.
This same consolidated email is now used consistently across:
- regular automatic fulfilment after customer checkout,
- admin manual-code recovery,
- admin bulk manual recovery,
- admin partner retry recovery.
Each code in the email is labelled with its product and variant so the customer can clearly distinguish which code belongs to which purchase.
Order response: dispatched_email, email_sent_at, voucher_codes
Three new fields are now included at the top level of every order response
(both client GET /orders/:id and admin GET /admin/orders/:id):
| Field | Description |
|---|---|
dispatched_email | Email address the consolidated voucher email was sent to. null until sent. |
email_sent_at | ISO 8601 timestamp of the last successful voucher-email dispatch. null until sent. |
voucher_codes | Flat deduplicated list of all codes across every item — same list the customer received. |
Manual dispatch now supports multiple codes (codes[])
POST /admin/orders/:id/fulfillment/manual-code can now accept an array of codes
in a single request. This is required whenever an order item has quantity > 1
because each unit needs its own unique voucher code.
{ "orderItemId": 123, "codes": [ { "code": "ABCD-1234-EFGH-5678", "pin": "1111" }, { "code": "WXYZ-9876-MNOP-5432", "pin": "2222" }, { "code": "QRST-1122-UVWX-3344", "pin": "3333" } ], "notes": "3× quantity — all codes from supplier"}- The legacy
code/pin/redemptionUrl/serialNumbershorthand still works for single-quantity items. - If both
codeandcodesare provided,codeswins. - All codes are persisted as individual
vouchersrows. - The customer receives one consolidated email containing every code.
- The audit attempt records
"FIRST-CODE (+N more)"incode_provided; the full array is inpartner_response.allCodes.
New order-level admin API: fulfill all items in one request
Admins can now recover the whole order in one call:
POST /admin/orders/:id/fulfillment/manual-codes
Payload is grouped by orderItemId, and each item must provide exactly one code
per purchased unit. Mixed partner sources are supported via item-level or
per-code partnerId.
New one-click resend API for admin panel
Admins can now resend the full consolidated order voucher email without entering codes again:
POST /admin/orders/:id/fulfillment/resend-email
This sends all persisted voucher codes currently linked to the order in one email, again labelled by product and variant.
Database linkage for reliable resend / multi-quantity visibility
Each stored voucher is now linked directly to its order_item via:
vouchers.order_item_id
This makes multi-quantity orders, consolidated resend, and top-level
voucher_codes reporting reliable even when an order contains multiple items or
multiple units of the same item.
partnerId on manual dispatch is optional
Set partnerId when the manually-entered code(s) originate from a known partner
so the vouchers rows link to that partner for analytics. Omit it for a pure
admin entry — rows are saved with partner_id: null.
2026.04.27 — PayStation payment method
paystation added to the payment_method Postgres enum
POST /orders with payment_method: "paystation" previously returned 500
because the payments.method column’s Postgres enum production.payment_method
did not include paystation. This is now fixed:
ALTER TYPE production.payment_method ADD VALUE IF NOT EXISTS 'paystation';The Prisma payments.create() call was also refactored to use $executeRawUnsafe
with an explicit ::production.payment_method cast, making the payment recording
immune to future enum additions without requiring a Prisma client rebuild or
Docker image rebuild.
2026.04.27 — Fulfillment hardening
Email failures on manual dispatch are now visible at the order level
Previously, when the voucher row was saved but the customer email failed
to send, the order item was still marked fulfilled = true and the order
could be promoted to SUCCEEDED — making the failure invisible outside
the attempts log.
New behaviour:
- The voucher is still saved and linked.
order_items.fulfilledstaysfalseandfulfillment_statusis set toDISPATCH_FAILED, surfacing the failure on the standard order list / detail endpoints.orders.statusstays onNEEDS_ATTENTION.- Related
fulfillment_failed/fulfillment_partialadmin alerts are not auto-resolved. - Re-submitting the same code is idempotent and triggers a fresh email attempt.
partnerId is optional on manual dispatch
Set it when the manually-entered voucher code originates from a known partner
so the saved voucher row is linked for analytics. Omit it for a pure
admin-manual entry — the row is saved with partner_id: null.
Database schema
partner_api_transactions.partner_idis now nullable withON DELETE SET NULL. Required so admin-manual transactions (no real partner) can be logged without a synthetic FK.orders.dispatched_email VARCHAR(255)— email address the consolidated voucher email was sent to.orders.email_sent_at TIMESTAMPTZ— timestamp of last successful dispatch.
Bug fixes
- Foreign key violation on
vouchers.partner_idduring admin manual dispatch —partner_id: 0is now coerced tonullinstoreVouchersandlogTransaction. - Misleading “voucher row not found” errors when the underlying
Prisma write failed —
storeVouchersnow re-throws the real error. PAYMENT_METHODSarray now includespaystation, fixing a400 Bad RequestonPOST /orderswithpayment_method: "paystation".- bKash 502 errors now include the gateway’s actual
statusCode/statusMessagein the response body for faster diagnosis. - Order status promotion now recognises
NEEDS_ATTENTIONas a valid target state inPATCH /admin/orders/:id/status. - Redirect-fulfillment emails now use the real
order_numberinstead of the internal numeric order id.
Tests
FulfillmentServiceunit suite — 17 tests (added multi-code dispatch test).AdminFulfillmentServicesuite hardened (12 tests).PaymentsServicesuite — 51 tests covering all three gateways.- All 80 fulfillment + payment unit tests pass.
2026.04.26 — Payments documentation & test coverage
New: dedicated Payments guide
End-to-end walkthrough covering all three live gateways:
- SSLCommerz — cards / banks / MFS aggregator
- bKash Tokenized Checkout (v1.2.0-beta)
- PayStation — multi-MFS (bKash, Nagad, Rocket, cards)
The guide documents the unified POST /api/v1/payments/initiate/:orderId
entry point, the per-gateway callback URLs, the server-side re-verification
we perform on every callback, the demo-MSISDN bypass for QA, and the
idempotency / Redis-locking guarantees on fulfillment.
Swagger / OpenAPI
Every payment endpoint and DTO now ships full Swagger annotations:
InitiatePaymentDto— enum-validatedmethod, examples for every fieldSSLCommerzCallbackDto,BkashCallbackDto,PayStationCallbackDto— documented field-by-fieldPOST /payments/initiate/:orderId,GET /payments/status/:transactionId, and the three*/callbackendpoints all carry@ApiOperationsummaries, parameter docs, and response codes
The regenerated specs are visible in the Client API reference.
2026.04.23 — Admin fulfillment recovery
New admin endpoints (/admin/orders)
Three new endpoints let operators recover paid orders whose vouchers were never delivered — without touching code or re-running background jobs.
| Method | Path | Purpose |
|---|---|---|
GET | /admin/orders/:id/fulfillment-attempts | Full audit trail of every recovery attempt for an order |
POST | /admin/orders/:id/fulfillment/manual-code | Admin enters one or more voucher codes; delivered via the same consolidated email pipeline as a regular order |
POST | /admin/orders/:id/fulfillment/partner-retry | Re-runs the upstream partner (Zendit etc.) for the unfulfilled item |
Behavior highlights
- Every attempt is logged — success, email failure, or partner failure all land in
fulfillment_attemptswith acorrelation_idso nothing is ever silently lost. - Partner failures never throw — if the partner call fails, the error payload (status, raw response, stack trace) is persisted so admins can inspect it without reading server logs.
- Same email pipeline — both recovery paths reuse
FulfillmentService(storeVouchers+ consolidated email); the customer receives the identical voucher email as a regular order. - Auto-resolution — on successful recovery the open
fulfillment_failed/fulfillment_partialadmin alert is automatically resolved with a reference to thecorrelation_id. - Order status flip — once all items are fulfilled the order transitions from
NEEDS_ATTENTION→SUCCEEDEDautomatically.
2026.04 — Developer Portal launch
- 🎉 New developer portal at
developer.bdvoucher.com(this site). - ✨ Scalar API Reference replaces Swagger UI as the primary in-app docs surface at
/docsand/admin/docs. - 🧪 Every endpoint now ships multi-language code samples (cURL, Node, Python, PHP, Go, Ruby) via
x-codeSamples. - 📚 Standardized error envelope
ErrorEnvelope+ reusable 400/401/403/404/422/429/500 responses on every operation. - 🏷️ All 42 controllers carry
@ApiTags+@ApiBearerAuth— sidebar grouping is tidy out of the box. - 🔍 Added Spectral + Redocly CI lint and
oasdiff-based breaking-change guard. - 📊 New
npm run docs:auditscores doc quality per operation (fails CI below threshold).
Earlier
Older changelog entries are tracked in the repository’s CHANGELOG.md and git tags.