@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +601 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +45 -0
  4. package/README.md +79 -0
  5. package/dist/deposits.d.ts +52 -0
  6. package/dist/deposits.d.ts.map +1 -0
  7. package/dist/deposits.js +71 -0
  8. package/dist/deposits.js.map +1 -0
  9. package/dist/index.d.ts +23 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +30 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/migrations/1789800000000-CreateCommerce.d.ts +38 -0
  14. package/dist/migrations/1789800000000-CreateCommerce.d.ts.map +1 -0
  15. package/dist/migrations/1789800000000-CreateCommerce.js +152 -0
  16. package/dist/migrations/1789800000000-CreateCommerce.js.map +1 -0
  17. package/dist/nestjs/commerce.service.d.ts +122 -0
  18. package/dist/nestjs/commerce.service.d.ts.map +1 -0
  19. package/dist/nestjs/commerce.service.js +337 -0
  20. package/dist/nestjs/commerce.service.js.map +1 -0
  21. package/dist/nestjs/index.d.ts +18 -0
  22. package/dist/nestjs/index.d.ts.map +1 -0
  23. package/dist/nestjs/index.js +27 -0
  24. package/dist/nestjs/index.js.map +1 -0
  25. package/dist/nestjs/payment.entity.d.ts +94 -0
  26. package/dist/nestjs/payment.entity.d.ts.map +1 -0
  27. package/dist/nestjs/payment.entity.js +181 -0
  28. package/dist/nestjs/payment.entity.js.map +1 -0
  29. package/dist/nestjs/payout-account.entity.d.ts +41 -0
  30. package/dist/nestjs/payout-account.entity.d.ts.map +1 -0
  31. package/dist/nestjs/payout-account.entity.js +83 -0
  32. package/dist/nestjs/payout-account.entity.js.map +1 -0
  33. package/dist/providers/port.d.ts +97 -0
  34. package/dist/providers/port.d.ts.map +1 -0
  35. package/dist/providers/port.js +16 -0
  36. package/dist/providers/port.js.map +1 -0
  37. package/dist/providers/stripe.d.ts +39 -0
  38. package/dist/providers/stripe.d.ts.map +1 -0
  39. package/dist/providers/stripe.js +221 -0
  40. package/dist/providers/stripe.js.map +1 -0
  41. package/nestjs/package.json +5 -0
  42. package/package.json +49 -0
  43. package/src/deposits.ts +96 -0
  44. package/src/index.ts +40 -0
  45. package/src/migrations/1789800000000-CreateCommerce.ts +156 -0
  46. package/src/nestjs/commerce.service.ts +476 -0
  47. package/src/nestjs/index.ts +24 -0
  48. package/src/nestjs/payment.entity.ts +150 -0
  49. package/src/nestjs/payout-account.entity.ts +56 -0
  50. package/src/providers/port.ts +108 -0
  51. 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
+ }