Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions app.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createLedger } from './ledger/create-ledger.js';
import { OrderRepository } from './orders/order-repository.js';
import { CardGateway } from './payments/card-gateway.js';
import { PaymentService } from './payments/payment-service.js';
Expand All @@ -12,12 +13,13 @@ import { FinanceExport } from './finance/reconciliation.js';
export function createCommerce() {
const orders = new OrderRepository();
const cardGateway = new CardGateway();
const payments = new PaymentService(cardGateway);
const ledger = createLedger();
const payments = new PaymentService(cardGateway, ledger);
const notifications = new NotificationService();
const refunds = new RefundService(orders, payments, new RefundPolicy(), notifications);
const checkout = new CheckoutApi(orders, refunds, payments);
const storefront = new StorefrontAccount(checkout);
const refunds = new RefundService(orders, ledger, new RefundPolicy(), notifications);
const checkout = new CheckoutApi(orders, refunds, payments, ledger);
const storefront = new StorefrontAccount(checkout, ledger);
const support = new SupportConsole(refunds);
const finance = new FinanceExport(orders, payments);
return { orders, cardGateway, payments, notifications, refunds, checkout, storefront, support, finance };
return { orders, cardGateway, payments, notifications, refunds, checkout, storefront, support, finance, ledger };
}
6 changes: 4 additions & 2 deletions checkout/checkout-api.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createLedger } from '../ledger/create-ledger.js';
import { OrderRepository } from '../orders/order-repository.js';
import { RefundService } from '../refunds/refund-service.js';
import { PaymentService } from '../payments/payment-service.js';

export class CheckoutApi {
constructor(orders = new OrderRepository(), refunds = new RefundService(), payments = new PaymentService()) {
constructor(orders = new OrderRepository(), refunds = new RefundService(), payments = new PaymentService(), ledger = createLedger()) {
this.orders = orders;
this.refunds = refunds;
this.payments = payments;
this.ledger = ledger;
}

requestRefund(customerId, request) {
Expand All @@ -18,6 +20,6 @@ export class CheckoutApi {
accountSummary(customerId) {
const orders = this.orders.list().filter(order => order.customerId === customerId);
const cardRefunds = orders.flatMap(order => this.payments.refundsForOrder(order));
return { customerId, orders, cardRefunds, storeCredit: 0 };
return { customerId, orders, cardRefunds, storeCredit: this.ledger.balance(customerId, 'EUR') };
}
}
10 changes: 10 additions & 0 deletions ledger/account-repository.js
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) };
}
}
11 changes: 11 additions & 0 deletions ledger/balance-reader.js
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);
}
}
13 changes: 13 additions & 0 deletions ledger/create-ledger.js
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);
}
14 changes: 14 additions & 0 deletions ledger/entry-repository.js
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 }));
}
}
42 changes: 42 additions & 0 deletions ledger/ledger-service.js
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);
}
}
11 changes: 11 additions & 0 deletions ledger/outbox.js
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 })); }
}
10 changes: 9 additions & 1 deletion payments/payment-service.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { createLedger } from '../ledger/create-ledger.js';
import { CardGateway } from './card-gateway.js';

export class PaymentService {
constructor(cardGateway = new CardGateway()) { this.cardGateway = cardGateway; }
constructor(cardGateway = new CardGateway(), ledger = createLedger()) {
this.cardGateway = cardGateway;
this.ledger = ledger;
}

recordSettlement(order, requestId) {
return this.ledger.recordSettlement(order, requestId);
}

refundOriginalPayment(order, amount, requestId) {
return this.cardGateway.reverseCharge({ paymentId: order.paymentId, amount, currency: order.currency, requestId });
Expand Down
10 changes: 5 additions & 5 deletions refunds/refund-service.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { OrderRepository } from '../orders/order-repository.js';
import { PaymentService } from '../payments/payment-service.js';
import { createLedger } from '../ledger/create-ledger.js';
import { RefundPolicy } from './refund-policy.js';
import { NotificationService } from '../notifications/notification-service.js';

export class RefundService {
#requests = new Map();

constructor(orders = new OrderRepository(), payments = new PaymentService(), policy = new RefundPolicy(), notifications = new NotificationService()) {
constructor(orders = new OrderRepository(), ledger = createLedger(), policy = new RefundPolicy(), notifications = new NotificationService()) {
this.orders = orders;
this.payments = payments;
this.ledger = ledger;
this.policy = policy;
this.notifications = notifications;
}
Expand All @@ -22,8 +22,8 @@ export class RefundService {
}
const order = this.orders.get(orderId);
this.policy.validate(order, amount);
const paymentRefund = this.payments.refundOriginalPayment(order, amount, requestId);
const refund = { ...paymentRefund, orderId, customerId: order.customerId, status: 'completed' };
const ledgerEntry = this.ledger.creditRefund(order, amount, requestId);
const refund = { ...ledgerEntry, orderId, customerId: order.customerId, status: 'completed' };
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore refunds to the original payment method

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. Running npm run demo confirms that the €49 refund produces no card reversals and a 4900-cent discrepancy in FinanceExport.reconcile(). Keep the original-payment reversal as the refund operation rather than replacing it with a ledger credit.

Useful? React with 👍 / 👎.

this.orders.recordRefund(orderId, refund);
this.notifications.sendRefundReceipt(refund);
this.#requests.set(requestId, refund);
Expand Down
10 changes: 7 additions & 3 deletions storefront/account-page.js
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read account balances through Checkout

renderBalance now bypasses CheckoutApi.accountSummary and accesses Ledger directly, contrary to the README's explicit requirement that Storefront access money operations through Checkout. This also makes the existing new StorefrontAccount(checkout) construction misleading: it creates a separate, empty ledger, so the displayed balance ignores credits available through the supplied Checkout instance. Expose the currency-aware balance through Checkout and have the view consume that interface instead of owning another money-service dependency.

Useful? React with 👍 / 👎.

}
}
38 changes: 38 additions & 0 deletions tests/ledger.test.js
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/);
});
7 changes: 3 additions & 4 deletions tests/refunds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ function fixture() {
return app;
}

test('a refund returns money to the original card', () => {
test('an instant refund updates the account balance', () => {
const app = fixture();
const result = app.storefront.refundOrder('customer-demo', 'order-1042', 4900, 'refund-demo');
assert.equal(result.destination, 'original-card');
assert.equal(app.cardGateway.listReversals()[0].amount, 4900);
assert.equal(app.finance.reconcile()[0].discrepancy, 0);
assert.equal(result.status, 'completed');
assert.equal(app.ledger.balance('customer-demo', 'EUR'), 4900);
});

test('a retry does not refund the card twice', () => {
Expand Down
Loading