@birtalanrobert/commerce 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +601 -0
- package/LICENSE +661 -0
- package/NOTICE +45 -0
- package/README.md +79 -0
- package/dist/deposits.d.ts +52 -0
- package/dist/deposits.d.ts.map +1 -0
- package/dist/deposits.js +71 -0
- package/dist/deposits.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/1789800000000-CreateCommerce.d.ts +38 -0
- package/dist/migrations/1789800000000-CreateCommerce.d.ts.map +1 -0
- package/dist/migrations/1789800000000-CreateCommerce.js +152 -0
- package/dist/migrations/1789800000000-CreateCommerce.js.map +1 -0
- package/dist/nestjs/commerce.service.d.ts +122 -0
- package/dist/nestjs/commerce.service.d.ts.map +1 -0
- package/dist/nestjs/commerce.service.js +337 -0
- package/dist/nestjs/commerce.service.js.map +1 -0
- package/dist/nestjs/index.d.ts +18 -0
- package/dist/nestjs/index.d.ts.map +1 -0
- package/dist/nestjs/index.js +27 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/nestjs/payment.entity.d.ts +94 -0
- package/dist/nestjs/payment.entity.d.ts.map +1 -0
- package/dist/nestjs/payment.entity.js +181 -0
- package/dist/nestjs/payment.entity.js.map +1 -0
- package/dist/nestjs/payout-account.entity.d.ts +41 -0
- package/dist/nestjs/payout-account.entity.d.ts.map +1 -0
- package/dist/nestjs/payout-account.entity.js +83 -0
- package/dist/nestjs/payout-account.entity.js.map +1 -0
- package/dist/providers/port.d.ts +97 -0
- package/dist/providers/port.d.ts.map +1 -0
- package/dist/providers/port.js +16 -0
- package/dist/providers/port.js.map +1 -0
- package/dist/providers/stripe.d.ts +39 -0
- package/dist/providers/stripe.d.ts.map +1 -0
- package/dist/providers/stripe.js +221 -0
- package/dist/providers/stripe.js.map +1 -0
- package/nestjs/package.json +5 -0
- package/package.json +49 -0
- package/src/deposits.ts +96 -0
- package/src/index.ts +40 -0
- package/src/migrations/1789800000000-CreateCommerce.ts +156 -0
- package/src/nestjs/commerce.service.ts +476 -0
- package/src/nestjs/index.ts +24 -0
- package/src/nestjs/payment.entity.ts +150 -0
- package/src/nestjs/payout-account.entity.ts +56 -0
- package/src/providers/port.ts +108 -0
- package/src/providers/stripe.ts +274 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
import type { DataSource, EntityManager } from 'typeorm';
|
|
3
|
+
import { InjectDataSource } from '@birtalanrobert/database';
|
|
4
|
+
import { runInTenantTransaction } from '@birtalanrobert/tenancy';
|
|
5
|
+
import { getActor } from '@birtalanrobert/context';
|
|
6
|
+
import { ConflictError, NotFoundError, ValidationError } from '@birtalanrobert/http';
|
|
7
|
+
import { canTakeMoney, type PayoutStatus } from '../deposits';
|
|
8
|
+
import type { PaymentProvider, ProviderEvent } from '../providers/port';
|
|
9
|
+
import { PayoutAccount } from './payout-account.entity';
|
|
10
|
+
import { Payment, PaymentRefund, type PaymentMethod } from './payment.entity';
|
|
11
|
+
|
|
12
|
+
/** The provider this deployment uses, injected so tests can supply their own. */
|
|
13
|
+
export const COMMERCE_PROVIDER = Symbol('COMMERCE_PROVIDER');
|
|
14
|
+
|
|
15
|
+
export interface TakePayment {
|
|
16
|
+
readonly subject: string;
|
|
17
|
+
readonly amount: number;
|
|
18
|
+
readonly currency: string;
|
|
19
|
+
readonly applicationFee?: number;
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
/** False holds the money instead of taking it. See `capture`. */
|
|
22
|
+
readonly capture?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Makes the charge idempotent across retries of the same act.
|
|
25
|
+
*
|
|
26
|
+
* A person pressing a button twice on a slow connection is the ordinary case,
|
|
27
|
+
* not an exotic one, and the second press must not produce a second charge.
|
|
28
|
+
*/
|
|
29
|
+
readonly reference: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecordPayment {
|
|
33
|
+
readonly subject: string;
|
|
34
|
+
readonly amount: number;
|
|
35
|
+
readonly currency: string;
|
|
36
|
+
readonly method: Exclude<PaymentMethod, 'card'>;
|
|
37
|
+
readonly detail?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Money between a customer and a business, and the record of it.
|
|
42
|
+
*
|
|
43
|
+
* **We never hold their funds** — the customer pays the business directly and
|
|
44
|
+
* our fee is taken on top — so almost everything here is about the *record*
|
|
45
|
+
* rather than the movement. That record has to be complete enough to produce a
|
|
46
|
+
* business's own takings years later, after the provider account is closed.
|
|
47
|
+
*
|
|
48
|
+
* Every method runs inside the tenant's policy, reads included: these tables are
|
|
49
|
+
* under row-level security, and an unbound read returns *nothing* rather than
|
|
50
|
+
* failing — which here means a revenue report of zero for a business that took
|
|
51
|
+
* money all month.
|
|
52
|
+
*/
|
|
53
|
+
@Injectable()
|
|
54
|
+
export class CommerceService {
|
|
55
|
+
constructor(
|
|
56
|
+
@InjectDataSource() private readonly dataSource: DataSource,
|
|
57
|
+
@Inject(COMMERCE_PROVIDER) private readonly provider: PaymentProvider,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
// ── Getting paid at all ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Where a business's money goes, and whether the provider will send it yet. */
|
|
63
|
+
async payoutAccount(tenantId: string): Promise<PayoutAccount | null> {
|
|
64
|
+
return runInTenantTransaction(
|
|
65
|
+
this.dataSource,
|
|
66
|
+
(scoped) =>
|
|
67
|
+
scoped
|
|
68
|
+
.getRepository(PayoutAccount)
|
|
69
|
+
.findOne({ where: { tenantId, provider: this.provider.name } }),
|
|
70
|
+
{ tenantId },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async payoutStatus(tenantId: string): Promise<PayoutStatus> {
|
|
75
|
+
return (await this.payoutAccount(tenantId))?.status ?? 'none';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Starts or resumes onboarding, creating the provider's account if needed.
|
|
80
|
+
*
|
|
81
|
+
* Called again as often as somebody presses the button: onboarding is a form
|
|
82
|
+
* people abandon and come back to, and the link is short-lived, so "resume"
|
|
83
|
+
* is the common case rather than the exception.
|
|
84
|
+
*/
|
|
85
|
+
async startOnboarding(
|
|
86
|
+
tenantId: string,
|
|
87
|
+
options: { country: string; email?: string; returnUrl: string; refreshUrl: string },
|
|
88
|
+
): Promise<{ url: string; expiresAt: Date }> {
|
|
89
|
+
const existing = await this.payoutAccount(tenantId);
|
|
90
|
+
|
|
91
|
+
const account = await this.provider.account(
|
|
92
|
+
existing?.externalId ?? null,
|
|
93
|
+
options.country,
|
|
94
|
+
options.email,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await this.saveAccount(tenantId, account);
|
|
98
|
+
|
|
99
|
+
return this.provider.onboard(account.externalId, options.returnUrl, options.refreshUrl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Asks the provider where onboarding stands, and records the answer. */
|
|
103
|
+
async refreshPayoutAccount(tenantId: string): Promise<PayoutStatus> {
|
|
104
|
+
const existing = await this.payoutAccount(tenantId);
|
|
105
|
+
if (!existing) return 'none';
|
|
106
|
+
|
|
107
|
+
const account = await this.provider.account(existing.externalId, 'RO');
|
|
108
|
+
await this.saveAccount(tenantId, account);
|
|
109
|
+
|
|
110
|
+
return account.status;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Taking money ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Charges a card, or holds one.
|
|
117
|
+
*
|
|
118
|
+
* Refused before the provider is called if the business cannot be paid out:
|
|
119
|
+
* a charge that succeeds into an account with no destination leaves the
|
|
120
|
+
* customer debited and the money nowhere anybody can see it, and the first
|
|
121
|
+
* the business hears is asking where it went.
|
|
122
|
+
*/
|
|
123
|
+
async take(tenantId: string, input: TakePayment): Promise<Payment> {
|
|
124
|
+
const account = await this.payoutAccount(tenantId);
|
|
125
|
+
|
|
126
|
+
if (!account || !canTakeMoney(account.status)) {
|
|
127
|
+
throw new ConflictError('This business cannot take card payments yet.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (input.amount <= 0) {
|
|
131
|
+
throw new ValidationError(
|
|
132
|
+
[{ field: 'amount', message: 'There is nothing to charge.' }],
|
|
133
|
+
'There is nothing to charge.',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = await this.provider.charge({
|
|
138
|
+
account: account.externalId,
|
|
139
|
+
amount: input.amount,
|
|
140
|
+
currency: input.currency,
|
|
141
|
+
applicationFee: input.applicationFee ?? 0,
|
|
142
|
+
subject: input.subject,
|
|
143
|
+
capture: input.capture ?? true,
|
|
144
|
+
...(input.description ? { description: input.description } : {}),
|
|
145
|
+
reference: input.reference,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return runInTenantTransaction(
|
|
149
|
+
this.dataSource,
|
|
150
|
+
async (scoped) => {
|
|
151
|
+
const repository = scoped.getRepository(Payment);
|
|
152
|
+
|
|
153
|
+
return repository.save(
|
|
154
|
+
repository.create({
|
|
155
|
+
tenantId,
|
|
156
|
+
subject: input.subject,
|
|
157
|
+
method: 'card',
|
|
158
|
+
state: result.state,
|
|
159
|
+
amount: String(input.amount),
|
|
160
|
+
currency: input.currency,
|
|
161
|
+
applicationFee: String(input.applicationFee ?? 0),
|
|
162
|
+
refunded: '0',
|
|
163
|
+
provider: this.provider.name,
|
|
164
|
+
externalId: result.externalId || null,
|
|
165
|
+
instrument: result.instrument ?? null,
|
|
166
|
+
// Only when the money actually moved. A hold has not moved it, and
|
|
167
|
+
// a report that counted holds would overstate a business's takings.
|
|
168
|
+
takenAt: result.state === 'captured' ? new Date() : null,
|
|
169
|
+
detail: result.detail ?? null,
|
|
170
|
+
recordedBy: getActor()?.id ?? null,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
{ tenantId },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Writes down money that arrived some other way.
|
|
180
|
+
*
|
|
181
|
+
* Cash at the counter, a card terminal, a meal voucher, a bank transfer.
|
|
182
|
+
* **Recording is not a lesser feature**: a salon is mostly cash, a restaurant
|
|
183
|
+
* takes vouchers, a box office takes notes — and a report that counted only
|
|
184
|
+
* what a provider processed would tell a business a fraction of its own
|
|
185
|
+
* takings while looking complete.
|
|
186
|
+
*/
|
|
187
|
+
async record(tenantId: string, input: RecordPayment): Promise<Payment> {
|
|
188
|
+
if (input.amount <= 0) {
|
|
189
|
+
throw new ValidationError(
|
|
190
|
+
[{ field: 'amount', message: 'There is nothing to record.' }],
|
|
191
|
+
'There is nothing to record.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return runInTenantTransaction(
|
|
196
|
+
this.dataSource,
|
|
197
|
+
async (scoped) => {
|
|
198
|
+
const repository = scoped.getRepository(Payment);
|
|
199
|
+
|
|
200
|
+
return repository.save(
|
|
201
|
+
repository.create({
|
|
202
|
+
tenantId,
|
|
203
|
+
subject: input.subject,
|
|
204
|
+
method: input.method,
|
|
205
|
+
// Captured immediately: the money is in the till. There is no
|
|
206
|
+
// provider to wait for and nothing that can fail later.
|
|
207
|
+
state: 'captured',
|
|
208
|
+
amount: String(input.amount),
|
|
209
|
+
currency: input.currency,
|
|
210
|
+
applicationFee: '0',
|
|
211
|
+
refunded: '0',
|
|
212
|
+
provider: null,
|
|
213
|
+
externalId: null,
|
|
214
|
+
takenAt: new Date(),
|
|
215
|
+
detail: input.detail ?? null,
|
|
216
|
+
// Who wrote it down, because a cash payment has no other evidence.
|
|
217
|
+
recordedBy: getActor()?.id ?? null,
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
{ tenantId },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Takes money that was only held.
|
|
227
|
+
*
|
|
228
|
+
* **The human decision has been made by the time this is called.** Charging a
|
|
229
|
+
* no-show fee automatically is how a business loses that customer
|
|
230
|
+
* permanently, so this package offers the mechanism and never the trigger.
|
|
231
|
+
*/
|
|
232
|
+
async capture(tenantId: string, paymentId: string, amount?: number): Promise<Payment> {
|
|
233
|
+
const payment = await this.find(tenantId, paymentId);
|
|
234
|
+
|
|
235
|
+
if (payment.state !== 'authorized' || !payment.externalId) {
|
|
236
|
+
throw new ConflictError('That payment is not being held.');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await this.provider.capture(payment.externalId, amount);
|
|
240
|
+
|
|
241
|
+
return this.update(tenantId, paymentId, {
|
|
242
|
+
state: result.state,
|
|
243
|
+
takenAt: result.state === 'captured' ? new Date() : null,
|
|
244
|
+
...(amount === undefined ? {} : { amount: String(amount) }),
|
|
245
|
+
detail: result.detail ?? null,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Lets a held card go without taking anything. */
|
|
250
|
+
async release(tenantId: string, paymentId: string): Promise<Payment> {
|
|
251
|
+
const payment = await this.find(tenantId, paymentId);
|
|
252
|
+
|
|
253
|
+
if (payment.state !== 'authorized' || !payment.externalId) {
|
|
254
|
+
throw new ConflictError('That payment is not being held.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await this.provider.release(payment.externalId);
|
|
258
|
+
|
|
259
|
+
return this.update(tenantId, paymentId, { state: 'cancelled' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Giving it back ───────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Refunds some or all of a payment, with a reason.
|
|
266
|
+
*
|
|
267
|
+
* The reason is required because "we refunded her ninety lei in March" is a
|
|
268
|
+
* question somebody asks a year later, and a blank answer cannot be defended.
|
|
269
|
+
*/
|
|
270
|
+
async refund(
|
|
271
|
+
tenantId: string,
|
|
272
|
+
paymentId: string,
|
|
273
|
+
amount: number,
|
|
274
|
+
reason: string,
|
|
275
|
+
): Promise<Payment> {
|
|
276
|
+
const payment = await this.find(tenantId, paymentId);
|
|
277
|
+
|
|
278
|
+
if (payment.state !== 'captured' && payment.state !== 'partially_refunded') {
|
|
279
|
+
throw new ConflictError('That payment cannot be refunded.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const already = Number(payment.refunded);
|
|
283
|
+
const remaining = Number(payment.amount) - already;
|
|
284
|
+
|
|
285
|
+
if (amount <= 0 || amount > remaining) {
|
|
286
|
+
throw new ValidationError(
|
|
287
|
+
[{ field: 'amount', message: `At most ${remaining} can be given back.` }],
|
|
288
|
+
`At most ${remaining} can be given back.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/*
|
|
293
|
+
* The provider first, then the record.
|
|
294
|
+
*
|
|
295
|
+
* A row saying money was returned when it was not is worse than no row: it
|
|
296
|
+
* is an answer to "did she get it back" that happens to be wrong, and the
|
|
297
|
+
* customer is the one who finds out.
|
|
298
|
+
*/
|
|
299
|
+
const external = payment.externalId
|
|
300
|
+
? await this.provider.refund({
|
|
301
|
+
externalId: payment.externalId,
|
|
302
|
+
amount,
|
|
303
|
+
reason,
|
|
304
|
+
reference: `${paymentId}-${already + amount}`,
|
|
305
|
+
})
|
|
306
|
+
: null;
|
|
307
|
+
|
|
308
|
+
return runInTenantTransaction(
|
|
309
|
+
this.dataSource,
|
|
310
|
+
async (scoped) => {
|
|
311
|
+
const refunds = scoped.getRepository(PaymentRefund);
|
|
312
|
+
|
|
313
|
+
await refunds.save(
|
|
314
|
+
refunds.create({
|
|
315
|
+
tenantId,
|
|
316
|
+
paymentId,
|
|
317
|
+
amount: String(amount),
|
|
318
|
+
reason,
|
|
319
|
+
externalId: external?.externalId ?? null,
|
|
320
|
+
refundedBy: getActor()?.id ?? null,
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const total = already + amount;
|
|
325
|
+
|
|
326
|
+
await scoped.getRepository(Payment).update(
|
|
327
|
+
{ id: paymentId, tenantId },
|
|
328
|
+
{
|
|
329
|
+
refunded: String(total),
|
|
330
|
+
state: total >= Number(payment.amount) ? 'refunded' : 'partially_refunded',
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return scoped.getRepository(Payment).findOneOrFail({ where: { id: paymentId, tenantId } });
|
|
335
|
+
},
|
|
336
|
+
{ tenantId },
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Reading ──────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** Everything taken for one thing: a booking, an order, a tab. */
|
|
343
|
+
async forSubject(tenantId: string, subject: string): Promise<Payment[]> {
|
|
344
|
+
return runInTenantTransaction(
|
|
345
|
+
this.dataSource,
|
|
346
|
+
(scoped) =>
|
|
347
|
+
scoped
|
|
348
|
+
.getRepository(Payment)
|
|
349
|
+
.find({ where: { tenantId, subject }, order: { createdAt: 'ASC' } }),
|
|
350
|
+
{ tenantId },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** What a payment has had given back, and why. */
|
|
355
|
+
async refundsFor(tenantId: string, paymentId: string): Promise<PaymentRefund[]> {
|
|
356
|
+
return runInTenantTransaction(
|
|
357
|
+
this.dataSource,
|
|
358
|
+
(scoped) =>
|
|
359
|
+
scoped
|
|
360
|
+
.getRepository(PaymentRefund)
|
|
361
|
+
.find({ where: { tenantId, paymentId }, order: { createdAt: 'ASC' } }),
|
|
362
|
+
{ tenantId },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── What the provider says afterwards ────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Records what a webhook said, whatever it was about.
|
|
370
|
+
*
|
|
371
|
+
* A payment the deployment has no record of is **ignored rather than
|
|
372
|
+
* inserted**: it belongs to another environment sharing the provider account,
|
|
373
|
+
* and inventing a row for it would put another system's money in this one's
|
|
374
|
+
* books.
|
|
375
|
+
*/
|
|
376
|
+
async settle(event: ProviderEvent): Promise<Payment | PayoutAccount | undefined> {
|
|
377
|
+
if (event.kind === 'account' && event.accountStatus) {
|
|
378
|
+
const rows = await this.dataSource.query<Array<{ tenant_id: string }>>(
|
|
379
|
+
`SELECT "tenant_id" FROM "mortar_payout_accounts" WHERE "external_id" = $1`,
|
|
380
|
+
[event.externalId],
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const tenantId = rows[0]?.tenant_id;
|
|
384
|
+
if (!tenantId) return undefined;
|
|
385
|
+
|
|
386
|
+
await this.saveAccount(tenantId, event.accountStatus);
|
|
387
|
+
return (await this.payoutAccount(tenantId)) ?? undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (event.kind !== 'payment' || !event.state) return undefined;
|
|
391
|
+
|
|
392
|
+
/*
|
|
393
|
+
* Found by the provider's identifier, which is the only thing both sides
|
|
394
|
+
* share — and read without a tenant because a webhook does not carry one.
|
|
395
|
+
* The row itself says whose it is.
|
|
396
|
+
*/
|
|
397
|
+
const rows = await this.dataSource.query<Array<{ id: string; tenant_id: string }>>(
|
|
398
|
+
`SELECT "id", "tenant_id" FROM "mortar_payments" WHERE "external_id" = $1`,
|
|
399
|
+
[event.externalId],
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const found = rows[0];
|
|
403
|
+
if (!found) return undefined;
|
|
404
|
+
|
|
405
|
+
const state = event.state === 'refunded' ? 'refunded' : event.state;
|
|
406
|
+
|
|
407
|
+
return this.update(found.tenant_id, found.id, {
|
|
408
|
+
state,
|
|
409
|
+
...(state === 'captured' ? { takenAt: new Date() } : {}),
|
|
410
|
+
...(event.instrument ? { instrument: event.instrument } : {}),
|
|
411
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Internals ────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
private async find(tenantId: string, paymentId: string): Promise<Payment> {
|
|
418
|
+
const payment = await runInTenantTransaction(
|
|
419
|
+
this.dataSource,
|
|
420
|
+
(scoped) => scoped.getRepository(Payment).findOne({ where: { id: paymentId, tenantId } }),
|
|
421
|
+
{ tenantId },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
if (!payment) throw new NotFoundError('Payment', paymentId);
|
|
425
|
+
return payment;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async update(
|
|
429
|
+
tenantId: string,
|
|
430
|
+
paymentId: string,
|
|
431
|
+
patch: Partial<Payment>,
|
|
432
|
+
manager?: EntityManager,
|
|
433
|
+
): Promise<Payment> {
|
|
434
|
+
const work = async (scoped: EntityManager): Promise<Payment> => {
|
|
435
|
+
await scoped.getRepository(Payment).update({ id: paymentId, tenantId }, patch);
|
|
436
|
+
return scoped.getRepository(Payment).findOneOrFail({ where: { id: paymentId, tenantId } });
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
return manager ? work(manager) : runInTenantTransaction(this.dataSource, work, { tenantId });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private async saveAccount(
|
|
443
|
+
tenantId: string,
|
|
444
|
+
account: { externalId: string; status: PayoutStatus; requirements: readonly string[] },
|
|
445
|
+
): Promise<void> {
|
|
446
|
+
await runInTenantTransaction(
|
|
447
|
+
this.dataSource,
|
|
448
|
+
async (scoped) => {
|
|
449
|
+
await scoped.query(
|
|
450
|
+
`INSERT INTO "mortar_payout_accounts"
|
|
451
|
+
("tenant_id", "provider", "external_id", "status", "requirements", "ready_at")
|
|
452
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
|
453
|
+
ON CONFLICT ("tenant_id", "provider")
|
|
454
|
+
DO UPDATE SET "external_id" = EXCLUDED."external_id",
|
|
455
|
+
"status" = EXCLUDED."status",
|
|
456
|
+
"requirements" = EXCLUDED."requirements",
|
|
457
|
+
-- Set once and never cleared: the first time a
|
|
458
|
+
-- provider agreed to pay a business out is a date
|
|
459
|
+
-- worth keeping, and a later restriction does not
|
|
460
|
+
-- unmake it.
|
|
461
|
+
"ready_at" = COALESCE("mortar_payout_accounts"."ready_at", EXCLUDED."ready_at"),
|
|
462
|
+
"updated_at" = now()`,
|
|
463
|
+
[
|
|
464
|
+
tenantId,
|
|
465
|
+
this.provider.name,
|
|
466
|
+
account.externalId,
|
|
467
|
+
account.status,
|
|
468
|
+
JSON.stringify([...account.requirements]),
|
|
469
|
+
account.status === 'ready' ? new Date() : null,
|
|
470
|
+
],
|
|
471
|
+
);
|
|
472
|
+
},
|
|
473
|
+
{ tenantId },
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts that need a database and a container.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the root entry point so that working out a deposit — which a
|
|
5
|
+
* console does while somebody drags a slider — does not drag TypeORM into a
|
|
6
|
+
* browser bundle.
|
|
7
|
+
*/
|
|
8
|
+
export { PayoutAccount } from './payout-account.entity';
|
|
9
|
+
export { Payment, PaymentRefund, type PaymentMethod, type PaymentState } from './payment.entity';
|
|
10
|
+
export {
|
|
11
|
+
COMMERCE_PROVIDER,
|
|
12
|
+
CommerceService,
|
|
13
|
+
type RecordPayment,
|
|
14
|
+
type TakePayment,
|
|
15
|
+
} from './commerce.service';
|
|
16
|
+
export { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
|
|
17
|
+
|
|
18
|
+
import { PayoutAccount } from './payout-account.entity';
|
|
19
|
+
import { Payment, PaymentRefund } from './payment.entity';
|
|
20
|
+
import { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
|
|
21
|
+
|
|
22
|
+
/** Register with the data source, the way every other mortar package is. */
|
|
23
|
+
export const commerceEntities = [PayoutAccount, Payment, PaymentRefund];
|
|
24
|
+
export const commerceMigrations = [CreateCommerce1789800000000];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Column, Entity, Index, Unique } from 'typeorm';
|
|
2
|
+
import { BaseEntity, MONEY_AMOUNT_COLUMN } from '@birtalanrobert/database';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How the money arrived.
|
|
6
|
+
*
|
|
7
|
+
* `card` is the only one this package processes. The rest are **recorded, not
|
|
8
|
+
* taken** — and recording them is not a lesser feature: a salon is mostly cash
|
|
9
|
+
* at the counter, a restaurant's till takes meal vouchers, and a box office
|
|
10
|
+
* takes notes. A report that only counts what a provider processed tells a
|
|
11
|
+
* business a fraction of its own takings, which is worse than telling it
|
|
12
|
+
* nothing because it looks complete.
|
|
13
|
+
*/
|
|
14
|
+
export type PaymentMethod = 'card' | 'cash' | 'terminal' | 'voucher' | 'transfer';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a payment is.
|
|
18
|
+
*
|
|
19
|
+
* `authorized` is separate from `captured` on purpose: a card held against a
|
|
20
|
+
* no-show fee is authorised and never captured unless the fee is applied, and
|
|
21
|
+
* that decision is a human one.
|
|
22
|
+
*/
|
|
23
|
+
export type PaymentState =
|
|
24
|
+
| 'pending'
|
|
25
|
+
| 'authorized'
|
|
26
|
+
| 'captured'
|
|
27
|
+
| 'failed'
|
|
28
|
+
| 'refunded'
|
|
29
|
+
| 'partially_refunded'
|
|
30
|
+
| 'cancelled';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One movement of money between a customer and a business.
|
|
34
|
+
*
|
|
35
|
+
* **The record outlives the provider.** Amounts, dates, what it was for and who
|
|
36
|
+
* decided are all here in full rather than as identifiers to fetch: a business
|
|
37
|
+
* has to be able to produce its own takings years later, when the provider
|
|
38
|
+
* account may be closed, the API version retired, or the vendor replaced.
|
|
39
|
+
*
|
|
40
|
+
* There is deliberately **no foreign key to the subject**. One product takes a
|
|
41
|
+
* deposit against an appointment, another against a seat, a third against a
|
|
42
|
+
* table's tab — and a key to any one of them is precisely what would stop this
|
|
43
|
+
* table being shared.
|
|
44
|
+
*/
|
|
45
|
+
@Entity('mortar_payments')
|
|
46
|
+
@Unique('uq_payments_tenant_id', ['tenantId', 'id'])
|
|
47
|
+
@Index('ix_payments_subject', ['tenantId', 'subject'])
|
|
48
|
+
@Index('ix_payments_taken', ['tenantId', 'takenAt'])
|
|
49
|
+
@Index('ix_payments_external', ['provider', 'externalId'])
|
|
50
|
+
export class Payment extends BaseEntity {
|
|
51
|
+
@Column('uuid')
|
|
52
|
+
tenantId!: string;
|
|
53
|
+
|
|
54
|
+
/** What it was for, as the owning product names it: `booking:<id>`. */
|
|
55
|
+
@Column('varchar', { length: 160 })
|
|
56
|
+
subject!: string;
|
|
57
|
+
|
|
58
|
+
@Column('varchar', { length: 16 })
|
|
59
|
+
method!: PaymentMethod;
|
|
60
|
+
|
|
61
|
+
@Column('varchar', { length: 24, default: 'pending' })
|
|
62
|
+
state!: PaymentState;
|
|
63
|
+
|
|
64
|
+
/** Minor units, and the currency it was taken in. Never a float. */
|
|
65
|
+
@Column(MONEY_AMOUNT_COLUMN)
|
|
66
|
+
amount!: string;
|
|
67
|
+
|
|
68
|
+
@Column('varchar', { length: 3 })
|
|
69
|
+
currency!: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Our cut, taken on top rather than out of the business's money.
|
|
73
|
+
*
|
|
74
|
+
* Recorded even when zero, because "this product charged nothing for that
|
|
75
|
+
* transaction" and "nobody wrote down what it charged" are different facts
|
|
76
|
+
* and only one of them survives an argument.
|
|
77
|
+
*/
|
|
78
|
+
@Column({ ...MONEY_AMOUNT_COLUMN, default: '0' })
|
|
79
|
+
applicationFee!: string;
|
|
80
|
+
|
|
81
|
+
/** Sum of everything given back. Never more than `amount`. */
|
|
82
|
+
@Column({ ...MONEY_AMOUNT_COLUMN, default: '0' })
|
|
83
|
+
refunded!: string;
|
|
84
|
+
|
|
85
|
+
@Column('varchar', { length: 32, nullable: true })
|
|
86
|
+
provider!: string | null;
|
|
87
|
+
|
|
88
|
+
/** The provider's identifier, which is what a webhook arrives carrying. */
|
|
89
|
+
@Column('varchar', { length: 128, nullable: true })
|
|
90
|
+
externalId!: string | null;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The last four digits and the brand, for a person to recognise it by.
|
|
94
|
+
*
|
|
95
|
+
* Never the number, never a token that could be charged from a database
|
|
96
|
+
* dump. What a receptionist needs is "Visa ending 4242", and what a customer
|
|
97
|
+
* needs is to know which of their cards was used.
|
|
98
|
+
*/
|
|
99
|
+
@Column('varchar', { length: 40, nullable: true })
|
|
100
|
+
instrument!: string | null;
|
|
101
|
+
|
|
102
|
+
/** When the money actually moved, which is not when the row was created. */
|
|
103
|
+
@Column('timestamptz', { nullable: true })
|
|
104
|
+
takenAt!: Date | null;
|
|
105
|
+
|
|
106
|
+
/** Why it failed, or what a person recorded about a cash payment. */
|
|
107
|
+
@Column('varchar', { length: 400, nullable: true })
|
|
108
|
+
detail!: string | null;
|
|
109
|
+
|
|
110
|
+
/** Who recorded it, for the payments a person entered by hand. */
|
|
111
|
+
@Column('uuid', { nullable: true })
|
|
112
|
+
recordedBy!: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Money given back, one row per act of giving it.
|
|
117
|
+
*
|
|
118
|
+
* Several partial refunds against one payment is ordinary — a deposit returned
|
|
119
|
+
* in part, a ticket refunded and its fee kept — and a single `refunded` column
|
|
120
|
+
* cannot say when each happened or why. The column stays as the running total,
|
|
121
|
+
* because that is what every read wants, and these rows are what explain it.
|
|
122
|
+
*/
|
|
123
|
+
@Entity('mortar_payment_refunds')
|
|
124
|
+
@Index('ix_payment_refunds_payment', ['tenantId', 'paymentId'])
|
|
125
|
+
export class PaymentRefund extends BaseEntity {
|
|
126
|
+
@Column('uuid')
|
|
127
|
+
tenantId!: string;
|
|
128
|
+
|
|
129
|
+
@Column('uuid')
|
|
130
|
+
paymentId!: string;
|
|
131
|
+
|
|
132
|
+
@Column(MONEY_AMOUNT_COLUMN)
|
|
133
|
+
amount!: string;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Why, in the words of whoever decided.
|
|
137
|
+
*
|
|
138
|
+
* Required rather than optional: "we refunded her ninety lei in March" is a
|
|
139
|
+
* question somebody asks a year later, and a blank reason is an answer
|
|
140
|
+
* nobody can defend.
|
|
141
|
+
*/
|
|
142
|
+
@Column('varchar', { length: 400 })
|
|
143
|
+
reason!: string;
|
|
144
|
+
|
|
145
|
+
@Column('varchar', { length: 128, nullable: true })
|
|
146
|
+
externalId!: string | null;
|
|
147
|
+
|
|
148
|
+
@Column('uuid', { nullable: true })
|
|
149
|
+
refundedBy!: string | null;
|
|
150
|
+
}
|