Repository navigation
feat: add instant refunds through a new Ledger service #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
27ba25e
881a9d0
b676d58
0a99141
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| export class LedgerAccountRepository { | ||
| #accounts = new Map(); | ||
|
|
||
| forCustomer(customerId, currency) { | ||
| if (!customerId) throw new Error('Customer ID required'); | ||
| const key = `${customerId}:${currency}`; | ||
| if (!this.#accounts.has(key)) this.#accounts.set(key, { id: key, customerId, currency }); | ||
| return { ...this.#accounts.get(key) }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { LedgerEntryRepository } from './entry-repository.js'; | ||
|
|
||
| export class LedgerBalanceReader { | ||
| constructor(entries = new LedgerEntryRepository()) { this.entries = entries; } | ||
|
|
||
| availableCredit(accountId) { | ||
| return this.entries.forAccount(accountId) | ||
| .filter(entry => entry.kind === 'refund-credit') | ||
| .reduce((total, entry) => total + entry.amount, 0); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { LedgerAccountRepository } from './account-repository.js'; | ||
| import { LedgerEntryRepository } from './entry-repository.js'; | ||
| import { LedgerBalanceReader } from './balance-reader.js'; | ||
| import { LedgerOutbox } from './outbox.js'; | ||
| import { LedgerService } from './ledger-service.js'; | ||
|
|
||
| export function createLedger() { | ||
| const accounts = new LedgerAccountRepository(); | ||
| const entries = new LedgerEntryRepository(); | ||
| const balances = new LedgerBalanceReader(entries); | ||
| const outbox = new LedgerOutbox(); | ||
| return new LedgerService(accounts, entries, balances, outbox); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export class LedgerEntryRepository { | ||
| #entries = new Map(); | ||
|
|
||
| insert(entry) { | ||
| const existing = this.#entries.get(entry.id); | ||
| if (existing && JSON.stringify(existing) !== JSON.stringify(entry)) throw new Error('Entry ID conflict'); | ||
| this.#entries.set(entry.id, { ...entry }); | ||
| return { ...entry }; | ||
| } | ||
|
|
||
| forAccount(accountId) { | ||
| return [...this.#entries.values()].filter(entry => entry.accountId === accountId).map(entry => ({ ...entry })); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { LedgerAccountRepository } from './account-repository.js'; | ||
| import { LedgerEntryRepository } from './entry-repository.js'; | ||
| import { LedgerBalanceReader } from './balance-reader.js'; | ||
| import { LedgerOutbox } from './outbox.js'; | ||
| import { assertAmount } from '../shared/money.js'; | ||
|
|
||
| /** Posts commerce money events and exposes customers' available store credit. */ | ||
| export class LedgerService { | ||
| constructor(accounts = new LedgerAccountRepository(), entries = new LedgerEntryRepository(), balances = new LedgerBalanceReader(entries), outbox = new LedgerOutbox()) { | ||
| this.accounts = accounts; | ||
| this.entries = entries; | ||
| this.balances = balances; | ||
| this.outbox = outbox; | ||
| } | ||
|
|
||
| creditRefund(order, amount, requestId) { | ||
| assertAmount(amount, order.currency); | ||
| const account = this.accounts.forCustomer(order.customerId, order.currency); | ||
| const entry = this.entries.insert({ | ||
| id: `ledger-${requestId}`, accountId: account.id, amount, currency: order.currency, | ||
| kind: 'refund-credit', orderId: order.id, destination: 'store-credit', | ||
| }); | ||
| this.outbox.enqueue(entry); | ||
| return entry; | ||
| } | ||
|
|
||
| recordSettlement(order, requestId) { | ||
| assertAmount(order.paidAmount, order.currency); | ||
| const account = this.accounts.forCustomer(order.customerId, order.currency); | ||
| const entry = this.entries.insert({ | ||
| id: `settlement-${requestId}`, accountId: account.id, amount: order.paidAmount, | ||
| currency: order.currency, kind: 'card-settlement', orderId: order.id, | ||
| }); | ||
| this.outbox.enqueue(entry); | ||
| return entry; | ||
| } | ||
|
|
||
| balance(customerId, currency) { | ||
| const account = this.accounts.forCustomer(customerId, currency); | ||
| return this.balances.availableCredit(account.id); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| export class LedgerOutbox { | ||
| #events = new Map(); | ||
|
|
||
| enqueue(entry) { | ||
| const event = { id: `event-${entry.id}`, kind: 'ledger.entry.posted', entryId: entry.id, accountId: entry.accountId }; | ||
| this.#events.set(event.id, event); | ||
| return { ...event }; | ||
| } | ||
|
|
||
| pending() { return [...this.#events.values()].map(event => ({ ...event })); } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,20 @@ | ||
| import { createLedger } from '../ledger/create-ledger.js'; | ||
| import { CheckoutApi } from '../checkout/checkout-api.js'; | ||
| import { formatMoney } from '../shared/money.js'; | ||
|
|
||
| /** Account view model consumed by the storefront UI. */ | ||
| export class StorefrontAccount { | ||
| constructor(checkout = new CheckoutApi()) { this.checkout = checkout; } | ||
| constructor(checkout = new CheckoutApi(), ledger = createLedger()) { | ||
| this.checkout = checkout; | ||
| this.ledger = ledger; | ||
| } | ||
|
|
||
| refundOrder(customerId, orderId, amount, requestId) { | ||
| return this.checkout.requestRefund(customerId, { orderId, amount, requestId }); | ||
| } | ||
|
|
||
| renderBalance(customerId, currency = 'EUR') { | ||
| const summary = this.checkout.accountSummary(customerId); | ||
| return `Store credit: ${formatMoney(summary.storeCredit, currency)}`; | ||
| const availableCredit = this.ledger.balance(customerId, currency); | ||
| return `Store credit: ${formatMoney(availableCredit, currency)}`; | ||
|
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { createLedger } from '../ledger/create-ledger.js'; | ||
|
|
||
| const order = { id: 'order-1042', customerId: 'customer-demo', paidAmount: 12900, currency: 'EUR' }; | ||
|
|
||
| test('a posted refund is immediately available as store credit', () => { | ||
| const ledger = createLedger(); | ||
| ledger.creditRefund(order, 4900, 'r-1'); | ||
| assert.equal(ledger.balance(order.customerId, 'EUR'), 4900); | ||
| }); | ||
|
|
||
| test('reposting the same refund does not double the balance', () => { | ||
| const ledger = createLedger(); | ||
| ledger.creditRefund(order, 4900, 'r-1'); | ||
| ledger.creditRefund(order, 4900, 'r-1'); | ||
| assert.equal(ledger.balance(order.customerId, 'EUR'), 4900); | ||
| assert.equal(ledger.outbox.pending().length, 1); | ||
| }); | ||
|
|
||
| test('settlements do not become spendable customer credit', () => { | ||
| const ledger = createLedger(); | ||
| ledger.recordSettlement(order, 's-1'); | ||
| assert.equal(ledger.balance(order.customerId, 'EUR'), 0); | ||
| }); | ||
|
|
||
| test('credit is isolated by customer and currency', () => { | ||
| const ledger = createLedger(); | ||
| ledger.creditRefund(order, 4900, 'r-1'); | ||
| assert.equal(ledger.balance('another-customer', 'EUR'), 0); | ||
| assert.equal(ledger.balance(order.customerId, 'CHF'), 0); | ||
| }); | ||
|
|
||
| test('conflicting ledger entry IDs are rejected', () => { | ||
| const ledger = createLedger(); | ||
| ledger.creditRefund(order, 4900, 'r-1'); | ||
| assert.throws(() => ledger.creditRefund(order, 5100, 'r-1'), /conflict/); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every successful refund now posts store credit instead of calling
PaymentService.refundOriginalPayment, violating the README's product contract. Both Storefront and Support mark the refund completed and reduce the remaining refundable amount without returning money to the customer's card. Runningnpm run democonfirms that the €49 refund produces no card reversals and a 4900-cent discrepancy inFinanceExport.reconcile(). Keep the original-payment reversal as the refund operation rather than replacing it with a ledger credit.Useful? React with 👍 / 👎.