Saltar al contenido principal

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" → triggers issueCredit()
  • SaleForm payment section → "Store Credit" option → calls redeemCredit()
  • 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_account table
  • Create store_credit_transaction table
  • 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 StoreCreditModule in apps/backend/src/app.module.ts
  • Integrate with customer-returns.service.ts: auto-issue when refundMethod = '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.tsx for manual issuance

Verification

  • Migration applies cleanly
  • Types regenerated
  • Backend builds
  • POST /store-credit/transactions issues credit to a customer account
  • GET /store-credit/accounts/customer/:customerId returns correct balance
  • POST /store-credit/redeem deducts 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>