Feature 05 — Store Credit
Phase: 2 Priority: 🟠 High Status: ⏳ Pending Depends on: Feature 02 (Return workflow issues store credit as refund method)
Context
Store credit is a monetary balance (in currency units) distinct from loyalty points. Customers receive it from returns, manual issuance, or promotional gifts. It is redeemable at checkout like cash.
Key difference from loyalty: store credit = real money. It lives in the business's liability. A loyalty point has a configured redemption value; store credit is issued in exact monetary amounts.
Integration points:
CustomerReturnForm→ refund method "Store Credit" → triggersissueCredit()SaleFormpayment section → "Store Credit" option → callsredeemCredit()CustomerPage→ shows current balance + transaction history
Task Checklist
Database
- Create migration:
packages/backend/database/src/migrations/2026-03-XX-store-credit.mjs - Create
store_credit_accounttable - Create
store_credit_transactiontable - After Feature 02 migration: add FK
customer_return.store_credit_id → store_credit_transaction.id - Run
pnpm run migration:local:push - Run
pnpm run generate:types
Backend
- Create module:
apps/backend/src/store-credit/ - Create
store-credit.module.ts - Create
domain/store-credit-repository.domain.ts - Create
infrastructure/store-credit.repository.ts - Create
application/store-credit.service.ts-
getOrCreateAccount(customerId, businessId, currencyId) -
getBalance(customerId, businessId) -
issueCredit(accountId, amount, referenceType, referenceId, note, createdBy) -
redeemCredit(accountId, amount, saleId, createdBy)— validates balance, transactional -
getTransactions(accountId, page, size)
-
- Create
interfaces/store-credit.controller.ts - Create DTOs
- Register
StoreCreditModuleinapps/backend/src/app.module.ts - Integrate with
customer-returns.service.ts: auto-issue whenrefundMethod = 'store_credit'
PWA Frontend
- Create
apps/frontend-pwa/src/types/storeCredit.ts - Create
apps/frontend-pwa/src/services/storeCreditService.ts - Extend
CustomerReturnForm.tsx— refund method selector + store credit preview - Extend
SaleForm.tsx— store credit payment option with balance display - Extend
CustomerPage.tsx— store credit balance card + transaction history - Optionally: standalone
StoreCreditPage.tsxfor manual issuance
Verification
- Migration applies cleanly
- Types regenerated
- Backend builds
-
POST /store-credit/transactionsissues credit to a customer account -
GET /store-credit/accounts/customer/:customerIdreturns correct balance -
POST /store-credit/redeemdeducts from balance (validates insufficient funds) - Return with refund_method='store_credit' auto-issues credit
- SaleForm shows balance when customer is selected, allows redemption
Database Schema
// packages/backend/database/src/migrations/2026-03-XX-store-credit.mjs
import { Kysely, sql } from "kysely";
export async function up(db) {
// 1. Account (one per customer per business)
await db.schema
.createTable("store_credit_account")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("customer_id", "uuid", (col) => col.notNull().references("customer.id").onDelete("cascade"))
.addColumn("balance", "numeric", (col) => col.notNull().defaultTo(0))
.addColumn("currency_id", "uuid", (col) => col.references("currency.id").onDelete("set null"))
.addColumn("is_active", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("updated_at", "timestamptz")
.execute();
await db.schema.createIndex("idx_store_credit_account_customer")
.on("store_credit_account")
.columns(["business_id", "customer_id"])
.unique()
.execute();
// 2. Ledger (immutable log of all credit changes)
await db.schema
.createTable("store_credit_transaction")
.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn("business_id", "uuid", (col) => col.notNull().references("business.id").onDelete("cascade"))
.addColumn("account_id", "uuid", (col) => col.notNull().references("store_credit_account.id").onDelete("cascade"))
.addColumn("type", "varchar", (col) => col.notNull())
// 'issue' | 'redeem' | 'expire' | 'adjust'
.addColumn("amount", "numeric", (col) => col.notNull())
// positive = issue/adjust up, negative = redeem/expire
.addColumn("reference_type", "varchar")
// 'return' | 'sale' | 'manual'
.addColumn("reference_id", "uuid")
.addColumn("expiry_date", "timestamptz")
.addColumn("note", "text")
.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("created_by", "uuid", (col) => col.references("user.id").onDelete("set null"))
.execute();
await db.schema.createIndex("idx_store_credit_transaction_account")
.on("store_credit_transaction")
.columns(["account_id", "created_at"])
.execute();
}
export async function down(db) {
await db.schema.dropIndex("idx_store_credit_transaction_account").execute();
await db.schema.dropIndex("idx_store_credit_account_customer").execute();
await db.schema.dropTable("store_credit_transaction").ifExists().execute();
await db.schema.dropTable("store_credit_account").ifExists().execute();
}
Backend Implementation
Domain Interface
// domain/store-credit-repository.domain.ts
export interface StoreCreditAccount {
id: string;
businessId: string;
customerId: string;
balance: number;
currencyId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date | null;
}
export interface StoreCreditTransaction {
id: string;
businessId: string;
accountId: string;
type: 'issue' | 'redeem' | 'expire' | 'adjust';
amount: number;
referenceType?: string;
referenceId?: string;
expiryDate?: Date;
note?: string;
createdAt: Date;
createdBy?: string;
}
export interface IStoreCreditRepository {
getOrCreateAccount(customerId: string, businessId: string, currencyId?: string): Promise<StoreCreditAccount>;
getAccountByCustomer(customerId: string, businessId: string): Promise<StoreCreditAccount | undefined>;
updateBalance(accountId: string, newBalance: number, trx?: Transaction): Promise<StoreCreditAccount>;
createTransaction(data: Omit<StoreCreditTransaction, 'id' | 'createdAt'>, trx?: Transaction): Promise<StoreCreditTransaction>;
getTransactions(accountId: string, page: number, size: number): Promise<IOffsetPagination<StoreCreditTransaction>>;
}
Service: store-credit.service.ts
@Injectable()
export class StoreCreditService {
async issueCredit(
customerId: string,
businessId: string,
amount: number,
options: { referenceType?: string; referenceId?: string; note?: string; createdBy: string; currencyId?: string }
): Promise<{ account: StoreCreditAccount; transaction: StoreCreditTransaction }> {
const account = await this.repository.getOrCreateAccount(customerId, businessId, options.currencyId);
return this.database.transaction().execute(async (trx) => {
const transaction = await this.repository.createTransaction({
businessId,
accountId: account.id,
type: 'issue',
amount,
referenceType: options.referenceType,
referenceId: options.referenceId,
note: options.note,
createdBy: options.createdBy,
}, trx);
const updatedAccount = await this.repository.updateBalance(account.id, account.balance + amount, trx);
return { account: updatedAccount, transaction };
});
}
async redeemCredit(
accountId: string,
amount: number,
saleId: string,
createdBy: string,
): Promise<{ account: StoreCreditAccount; transaction: StoreCreditTransaction }> {
const account = await this.repository.getAccountById(accountId);
if (!account) throw new NotFoundException('Store credit account not found');
if (account.balance < amount) throw new BadRequestException(`Insufficient store credit. Available: ${account.balance}`);
return this.database.transaction().execute(async (trx) => {
const transaction = await this.repository.createTransaction({
businessId: account.businessId,
accountId,
type: 'redeem',
amount: -amount, // negative = deduction
referenceType: 'sale',
referenceId: saleId,
createdBy,
}, trx);
const updatedAccount = await this.repository.updateBalance(accountId, account.balance - amount, trx);
return { account: updatedAccount, transaction };
});
}
}
Endpoints: store-credit.controller.ts
@Controller("store-credit")
export class StoreCreditController {
@Post("accounts")
createAccount(@Body() dto: CreateStoreCreditAccountDTO)
@Get("accounts/customer/:customerId")
getByCustomer(
@Param("customerId") customerId: string,
@Query("businessId") businessId: string,
)
@Post("transactions")
issueOrAdjust(@Body() dto: IssueCreditDTO)
// Used for manual issuance and adjustments
@Post("redeem")
redeem(@Body() dto: RedeemCreditDTO)
// { accountId, amount, saleId, createdBy }
@Get("transactions")
getTransactions(
@Query("accountId") accountId: string,
@Query("page") page: number,
@Query("size") size: number,
)
}
PWA Frontend
Types: types/storeCredit.ts
export interface StoreCreditAccount {
id: string;
customerId: string;
businessId: string;
balance: number;
currencyId?: string;
isActive: boolean;
}
export interface StoreCreditTransaction {
id: string;
accountId: string;
type: 'issue' | 'redeem' | 'expire' | 'adjust';
amount: number;
referenceType?: string;
referenceId?: string;
note?: string;
createdAt: string;
}
Service: storeCreditService.ts
export async function getStoreCreditBalance(token: string, businessId: string, customerId: string): Promise<StoreCreditAccount | null>
export async function issueStoreCredit(token: string, data: {
customerId: string;
businessId: string;
amount: number;
referenceType?: string;
referenceId?: string;
note?: string;
createdBy: string;
}): Promise<{ account: StoreCreditAccount; transaction: StoreCreditTransaction }>
export async function redeemStoreCredit(token: string, data: {
accountId: string;
amount: number;
saleId: string;
createdBy: string;
}): Promise<{ account: StoreCreditAccount; transaction: StoreCreditTransaction }>
export async function getStoreCreditTransactions(token: string, accountId: string, page?: number, size?: number): Promise<IOffsetPagination<StoreCreditTransaction>>
Extension: SaleForm.tsx — Store Credit Payment Option
// In payment section, when customer has store credit balance:
{storeCreditAccount && storeCreditAccount.balance > 0 && (
<div className="border rounded p-3 bg-green-50">
<div className="flex justify-between">
<span className="font-medium">Store Credit Available</span>
<span className="font-bold text-green-700">
${storeCreditAccount.balance.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Checkbox checked={useStoreCredit} onCheckedChange={setUseStoreCredit} />
<label>Apply store credit</label>
{useStoreCredit && (
<Input
type="number"
step="0.01"
max={Math.min(storeCreditAccount.balance, saleTotal)}
value={storeCreditAmount}
onChange={(e) => setStoreCreditAmount(Number(e.target.value))}
placeholder="Amount to apply"
/>
)}
</div>
</div>
)}
Extension: CustomerPage.tsx — Store Credit Card
<Card>
<CardHeader>Store Credit</CardHeader>
<CardContent>
<div className="text-3xl font-bold">${storeCreditAccount?.balance?.toFixed(2) ?? '0.00'}</div>
<Button variant="outline" size="sm" onClick={() => setShowIssueModal(true)}>
Issue Credit
</Button>
</CardContent>
</Card>
{/* Transaction history */}
<Table>
<thead><tr><th>Date</th><th>Type</th><th>Amount</th><th>Reference</th></tr></thead>
<tbody>
{transactions.map(tx => (
<tr key={tx.id}>
<td>{formatDate(tx.createdAt)}</td>
<td><Badge>{tx.type}</Badge></td>
<td className={tx.amount > 0 ? 'text-green-600' : 'text-red-600'}>
{tx.amount > 0 ? '+' : ''}{tx.amount.toFixed(2)}
</td>
<td>{tx.referenceType} {tx.referenceId?.slice(0, 8)}</td>
</tr>
))}
</tbody>
</Table>