@birtalanrobert/commerce 1.0.0 → 2.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 (44) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts +30 -0
  6. package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts.map +1 -0
  7. package/dist/migrations/1790000000000-AddSavedCardsAndKind.js +89 -0
  8. package/dist/migrations/1790000000000-AddSavedCardsAndKind.js.map +1 -0
  9. package/dist/nestjs/commerce.service.d.ts +65 -1
  10. package/dist/nestjs/commerce.service.d.ts.map +1 -1
  11. package/dist/nestjs/commerce.service.js +98 -2
  12. package/dist/nestjs/commerce.service.js.map +1 -1
  13. package/dist/nestjs/index.d.ts +6 -3
  14. package/dist/nestjs/index.d.ts.map +1 -1
  15. package/dist/nestjs/index.js +9 -3
  16. package/dist/nestjs/index.js.map +1 -1
  17. package/dist/nestjs/payment.entity.d.ts +11 -19
  18. package/dist/nestjs/payment.entity.d.ts.map +1 -1
  19. package/dist/nestjs/payment.entity.js +13 -0
  20. package/dist/nestjs/payment.entity.js.map +1 -1
  21. package/dist/nestjs/saved-card.entity.d.ts +42 -0
  22. package/dist/nestjs/saved-card.entity.d.ts.map +1 -0
  23. package/dist/nestjs/saved-card.entity.js +109 -0
  24. package/dist/nestjs/saved-card.entity.js.map +1 -0
  25. package/dist/payments.d.ts +54 -0
  26. package/dist/payments.d.ts.map +1 -0
  27. package/dist/payments.js +30 -0
  28. package/dist/payments.js.map +1 -0
  29. package/dist/providers/port.d.ts +68 -0
  30. package/dist/providers/port.d.ts.map +1 -1
  31. package/dist/providers/stripe.d.ts +12 -1
  32. package/dist/providers/stripe.d.ts.map +1 -1
  33. package/dist/providers/stripe.js +71 -1
  34. package/dist/providers/stripe.js.map +1 -1
  35. package/package.json +12 -4
  36. package/src/index.ts +11 -0
  37. package/src/migrations/1790000000000-AddSavedCardsAndKind.ts +92 -0
  38. package/src/nestjs/commerce.service.ts +170 -3
  39. package/src/nestjs/index.ts +15 -3
  40. package/src/nestjs/payment.entity.ts +17 -25
  41. package/src/nestjs/saved-card.entity.ts +69 -0
  42. package/src/payments.ts +67 -0
  43. package/src/providers/port.ts +74 -0
  44. package/src/providers/stripe.ts +90 -1
@@ -0,0 +1,92 @@
1
+ import type { MigrationInterface, QueryRunner } from 'typeorm';
2
+ import { enableRlsSql } from '@birtalanrobert/tenancy';
3
+
4
+ /**
5
+ * A card kept for later, and what each payment was actually for.
6
+ *
7
+ * ## `mortar_saved_cards`
8
+ *
9
+ * The strongest thing a business can do about people not turning up short of
10
+ * taking their money: nothing leaves the customer's account, and the card is
11
+ * there if a fee is later decided on. A **hold is not a substitute** — providers
12
+ * expire an authorisation within days and an appointment is usually further
13
+ * away than that — which is why this is a table and not a longer-lived hold.
14
+ *
15
+ * The consent is stored as **text with a time**, not as a boolean. A flag says
16
+ * somebody clicked; the sentence says what they were told they were agreeing
17
+ * to, and only the second is worth anything when they say they were not told.
18
+ *
19
+ * ## `kind` on a payment
20
+ *
21
+ * A tip belongs to the person who earned it and a product to neither the
22
+ * service nor the diary. Counting all three as service income tells a business
23
+ * its haircuts are more profitable than they are, and nothing computed
24
+ * afterwards can separate them again — so the distinction goes in the row, with
25
+ * `sale` as the default so every existing payment keeps meaning what it meant.
26
+ */
27
+ export class AddSavedCardsAndKind1790000000000 implements MigrationInterface {
28
+ name = 'AddSavedCardsAndKind1790000000000';
29
+
30
+ public async up(queryRunner: QueryRunner): Promise<void> {
31
+ await queryRunner.query(`
32
+ ALTER TABLE "mortar_payments"
33
+ ADD COLUMN "kind" varchar(16) NOT NULL DEFAULT 'sale'
34
+ `);
35
+
36
+ await queryRunner.query(`
37
+ ALTER TABLE "mortar_payments"
38
+ ADD CONSTRAINT "ck_payments_kind"
39
+ CHECK ("kind" IN ('sale', 'deposit', 'fee', 'tip', 'product'))
40
+ `);
41
+
42
+ await queryRunner.query(`
43
+ CREATE TABLE "mortar_saved_cards" (
44
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
45
+ "created_at" timestamptz NOT NULL DEFAULT now(),
46
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
47
+ "tenant_id" uuid NOT NULL,
48
+ -- Whose card it is, as the owning product names it. No foreign key, for
49
+ -- the same reason a payment has none: one product hangs a card off a
50
+ -- salon's client, another off a tenant's guest.
51
+ "subject" varchar(160) NOT NULL,
52
+ "provider" varchar(32) NOT NULL DEFAULT 'stripe',
53
+ -- The customer on *our* account. Under a destination charge the money
54
+ -- lands on the business's account while the card stays ours to charge,
55
+ -- and one created on the business's account cannot be used from here.
56
+ "customer_ref" varchar(128) NOT NULL,
57
+ "payment_method_ref" varchar(128) NOT NULL,
58
+ -- Enough to recognise it, and nothing more. We never see the number.
59
+ "brand" varchar(24),
60
+ "last4" varchar(4),
61
+ "expiry_month" int,
62
+ "expiry_year" int,
63
+ -- What they were told they were agreeing to, in the language they read
64
+ -- it in. Required: a card charged on a consent nobody can produce is an
65
+ -- argument the business loses.
66
+ "consent_text" varchar(1000) NOT NULL,
67
+ "consented_at" timestamptz NOT NULL,
68
+ "stored_by" uuid,
69
+ CONSTRAINT "pk_saved_cards" PRIMARY KEY ("id"),
70
+ CONSTRAINT "uq_saved_cards_tenant_id" UNIQUE ("tenant_id", "id"),
71
+ CONSTRAINT "uq_saved_cards_method" UNIQUE ("tenant_id", "payment_method_ref")
72
+ )
73
+ `);
74
+
75
+ await queryRunner.query(`
76
+ CREATE INDEX "ix_saved_cards_subject"
77
+ ON "mortar_saved_cards" ("tenant_id", "subject")
78
+ `);
79
+
80
+ for (const statement of enableRlsSql('mortar_saved_cards')) {
81
+ await queryRunner.query(statement);
82
+ }
83
+ }
84
+
85
+ public async down(queryRunner: QueryRunner): Promise<void> {
86
+ await queryRunner.query(`DROP TABLE IF EXISTS "mortar_saved_cards" CASCADE`);
87
+ await queryRunner.query(`
88
+ ALTER TABLE "mortar_payments" DROP CONSTRAINT IF EXISTS "ck_payments_kind"
89
+ `);
90
+ await queryRunner.query(`ALTER TABLE "mortar_payments" DROP COLUMN IF EXISTS "kind"`);
91
+ }
92
+ }
@@ -7,7 +7,9 @@ import { ConflictError, NotFoundError, ValidationError } from '@birtalanrobert/h
7
7
  import { canTakeMoney, type PayoutStatus } from '../deposits';
8
8
  import type { PaymentProvider, ProviderEvent } from '../providers/port';
9
9
  import { PayoutAccount } from './payout-account.entity';
10
+ import { isRefundable, type PaymentKind } from '../payments';
10
11
  import { Payment, PaymentRefund, type PaymentMethod } from './payment.entity';
12
+ import { SavedCard } from './saved-card.entity';
11
13
 
12
14
  /** The provider this deployment uses, injected so tests can supply their own. */
13
15
  export const COMMERCE_PROVIDER = Symbol('COMMERCE_PROVIDER');
@@ -18,6 +20,15 @@ export interface TakePayment {
18
20
  readonly currency: string;
19
21
  readonly applicationFee?: number;
20
22
  readonly description?: string;
23
+ /** What it is for. `sale` unless said otherwise. */
24
+ readonly kind?: PaymentKind;
25
+ /**
26
+ * A stored card to charge without anybody present.
27
+ *
28
+ * The whole reason for keeping one: a fee is decided days after the fact, and
29
+ * the customer is not there to be asked.
30
+ */
31
+ readonly savedCardId?: string;
21
32
  /** False holds the money instead of taking it. See `capture`. */
22
33
  readonly capture?: boolean;
23
34
  /**
@@ -34,9 +45,32 @@ export interface RecordPayment {
34
45
  readonly amount: number;
35
46
  readonly currency: string;
36
47
  readonly method: Exclude<PaymentMethod, 'card'>;
48
+ readonly kind?: PaymentKind;
37
49
  readonly detail?: string;
38
50
  }
39
51
 
52
+ /**
53
+ * A charge, and what the browser needs to finish it.
54
+ *
55
+ * The secret is returned rather than stored: a charge created on the server has
56
+ * no card attached to it yet, and the card is entered against the provider's own
57
+ * script so the number never reaches us. Without handing it back, a payment can
58
+ * be created and never paid — which a customer reads as "it took my booking and
59
+ * lost my money".
60
+ */
61
+ export interface TakenPayment {
62
+ readonly payment: Payment;
63
+ readonly clientSecret?: string;
64
+ }
65
+
66
+ /** A card being stored, and what the browser confirms it with. */
67
+ export interface CardToSave {
68
+ readonly subject: string;
69
+ readonly reference: string;
70
+ /** What the customer was told they were agreeing to, in their own language. */
71
+ readonly consentText: string;
72
+ }
73
+
40
74
  /**
41
75
  * Money between a customer and a business, and the record of it.
42
76
  *
@@ -120,13 +154,19 @@ export class CommerceService {
120
154
  * customer debited and the money nowhere anybody can see it, and the first
121
155
  * the business hears is asking where it went.
122
156
  */
123
- async take(tenantId: string, input: TakePayment): Promise<Payment> {
157
+ async take(tenantId: string, input: TakePayment): Promise<TakenPayment> {
124
158
  const account = await this.payoutAccount(tenantId);
125
159
 
126
160
  if (!account || !canTakeMoney(account.status)) {
127
161
  throw new ConflictError('This business cannot take card payments yet.');
128
162
  }
129
163
 
164
+ const saved = input.savedCardId ? await this.savedCard(tenantId, input.savedCardId) : null;
165
+
166
+ if (input.savedCardId && !saved) {
167
+ throw new NotFoundError('SavedCard', input.savedCardId);
168
+ }
169
+
130
170
  if (input.amount <= 0) {
131
171
  throw new ValidationError(
132
172
  [{ field: 'amount', message: 'There is nothing to charge.' }],
@@ -142,10 +182,11 @@ export class CommerceService {
142
182
  subject: input.subject,
143
183
  capture: input.capture ?? true,
144
184
  ...(input.description ? { description: input.description } : {}),
185
+ ...(saved ? { customer: saved.customerRef, paymentMethod: saved.paymentMethodRef } : {}),
145
186
  reference: input.reference,
146
187
  });
147
188
 
148
- return runInTenantTransaction(
189
+ const payment = await runInTenantTransaction(
149
190
  this.dataSource,
150
191
  async (scoped) => {
151
192
  const repository = scoped.getRepository(Payment);
@@ -155,6 +196,7 @@ export class CommerceService {
155
196
  tenantId,
156
197
  subject: input.subject,
157
198
  method: 'card',
199
+ kind: input.kind ?? 'sale',
158
200
  state: result.state,
159
201
  amount: String(input.amount),
160
202
  currency: input.currency,
@@ -173,6 +215,130 @@ export class CommerceService {
173
215
  },
174
216
  { tenantId },
175
217
  );
218
+
219
+ /*
220
+ * The secret is handed back and never written down.
221
+ *
222
+ * It authorises whoever holds it to pay this one charge, so it belongs in
223
+ * the response to the person paying and nowhere else — not in a column, not
224
+ * in a log line, and not in an error.
225
+ */
226
+ return { payment, ...(result.clientSecret ? { clientSecret: result.clientSecret } : {}) };
227
+ }
228
+
229
+ /**
230
+ * Starts storing a card without charging it, and records what was agreed.
231
+ *
232
+ * Two steps, because there is a browser in the middle: this creates the
233
+ * intent and hands back a secret, and `confirmCard` writes the row once the
234
+ * provider says a card really is stored. Believing the browser instead would
235
+ * mean a business holding a card reference that charges nothing.
236
+ */
237
+ async saveCard(
238
+ tenantId: string,
239
+ input: CardToSave,
240
+ ): Promise<{ externalId: string; clientSecret: string }> {
241
+ const existing = await this.savedCards(tenantId, input.subject);
242
+
243
+ const result = await this.provider.saveCard({
244
+ subject: input.subject,
245
+ reference: input.reference,
246
+ // Reuse the customer this person already has, so a second card joins the
247
+ // first rather than creating a stranger with the same name.
248
+ ...(existing[0] ? { customer: existing[0].customerRef } : {}),
249
+ });
250
+
251
+ return { externalId: result.externalId, clientSecret: result.clientSecret };
252
+ }
253
+
254
+ /**
255
+ * Writes down the card the provider says is now stored.
256
+ *
257
+ * Read back from the provider rather than taken from the browser: what a page
258
+ * reports is what a page was told to report, and this row is what a business
259
+ * will later charge real money against.
260
+ */
261
+ async confirmCard(
262
+ tenantId: string,
263
+ externalId: string,
264
+ input: CardToSave,
265
+ ): Promise<SavedCard | null> {
266
+ const stored = await this.provider.storedCard(externalId);
267
+ if (!stored) return null;
268
+
269
+ return runInTenantTransaction(
270
+ this.dataSource,
271
+ async (scoped) => {
272
+ const repository = scoped.getRepository(SavedCard);
273
+
274
+ const already = await repository.findOne({
275
+ where: { tenantId, paymentMethodRef: stored.paymentMethod },
276
+ });
277
+
278
+ // Confirming twice is the ordinary case on a slow connection, not an
279
+ // exotic one, and it must not leave two rows for one card.
280
+ if (already) return already;
281
+
282
+ return repository.save(
283
+ repository.create({
284
+ tenantId,
285
+ subject: input.subject,
286
+ provider: this.provider.name,
287
+ customerRef: stored.customer,
288
+ paymentMethodRef: stored.paymentMethod,
289
+ brand: stored.brand ?? null,
290
+ last4: stored.last4 ?? null,
291
+ expiryMonth: stored.expiryMonth ?? null,
292
+ expiryYear: stored.expiryYear ?? null,
293
+ consentText: input.consentText,
294
+ consentedAt: new Date(),
295
+ storedBy: getActor()?.id ?? null,
296
+ }),
297
+ );
298
+ },
299
+ { tenantId },
300
+ );
301
+ }
302
+
303
+ /** Every card kept for somebody, newest first. */
304
+ async savedCards(tenantId: string, subject: string): Promise<SavedCard[]> {
305
+ return runInTenantTransaction(
306
+ this.dataSource,
307
+ (scoped) =>
308
+ scoped
309
+ .getRepository(SavedCard)
310
+ .find({ where: { tenantId, subject }, order: { createdAt: 'DESC' } }),
311
+ { tenantId },
312
+ );
313
+ }
314
+
315
+ async savedCard(tenantId: string, id: string): Promise<SavedCard | null> {
316
+ return runInTenantTransaction(
317
+ this.dataSource,
318
+ (scoped) => scoped.getRepository(SavedCard).findOne({ where: { tenantId, id } }),
319
+ { tenantId },
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Forgets a card, at the customer's request or the business's.
325
+ *
326
+ * The provider is told first. A row deleted while the provider still holds
327
+ * the card is a card nobody can see and anybody with the reference can
328
+ * charge; the reverse — a detached card with a row still here — is merely a
329
+ * charge that fails.
330
+ */
331
+ async forgetCard(tenantId: string, id: string): Promise<void> {
332
+ const card = await this.savedCard(tenantId, id);
333
+ if (!card) return;
334
+
335
+ await this.provider.forgetCard(card.paymentMethodRef);
336
+
337
+ await runInTenantTransaction(
338
+ this.dataSource,
339
+ (scoped) => scoped.getRepository(SavedCard).delete({ tenantId, id }),
340
+ { tenantId },
341
+ );
176
342
  }
177
343
 
178
344
  /**
@@ -202,6 +368,7 @@ export class CommerceService {
202
368
  tenantId,
203
369
  subject: input.subject,
204
370
  method: input.method,
371
+ kind: input.kind ?? 'sale',
205
372
  // Captured immediately: the money is in the till. There is no
206
373
  // provider to wait for and nothing that can fail later.
207
374
  state: 'captured',
@@ -275,7 +442,7 @@ export class CommerceService {
275
442
  ): Promise<Payment> {
276
443
  const payment = await this.find(tenantId, paymentId);
277
444
 
278
- if (payment.state !== 'captured' && payment.state !== 'partially_refunded') {
445
+ if (!isRefundable(payment.state)) {
279
446
  throw new ConflictError('That payment cannot be refunded.');
280
447
  }
281
448
 
@@ -6,19 +6,31 @@
6
6
  * browser bundle.
7
7
  */
8
8
  export { PayoutAccount } from './payout-account.entity';
9
- export { Payment, PaymentRefund, type PaymentMethod, type PaymentState } from './payment.entity';
9
+ export {
10
+ Payment,
11
+ PaymentRefund,
12
+ type PaymentKind,
13
+ type PaymentMethod,
14
+ type PaymentState,
15
+ } from './payment.entity';
16
+ export { SavedCard } from './saved-card.entity';
10
17
  export {
11
18
  COMMERCE_PROVIDER,
12
19
  CommerceService,
20
+ type CardToSave,
13
21
  type RecordPayment,
22
+ type TakenPayment,
14
23
  type TakePayment,
15
24
  } from './commerce.service';
16
25
  export { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
26
+ export { AddSavedCardsAndKind1790000000000 } from '../migrations/1790000000000-AddSavedCardsAndKind';
17
27
 
18
28
  import { PayoutAccount } from './payout-account.entity';
19
29
  import { Payment, PaymentRefund } from './payment.entity';
30
+ import { SavedCard } from './saved-card.entity';
20
31
  import { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
32
+ import { AddSavedCardsAndKind1790000000000 } from '../migrations/1790000000000-AddSavedCardsAndKind';
21
33
 
22
34
  /** Register with the data source, the way every other mortar package is. */
23
- export const commerceEntities = [PayoutAccount, Payment, PaymentRefund];
24
- export const commerceMigrations = [CreateCommerce1789800000000];
35
+ export const commerceEntities = [PayoutAccount, Payment, PaymentRefund, SavedCard];
36
+ export const commerceMigrations = [CreateCommerce1789800000000, AddSavedCardsAndKind1790000000000];
@@ -1,33 +1,14 @@
1
1
  import { Column, Entity, Index, Unique } from 'typeorm';
2
2
  import { BaseEntity, MONEY_AMOUNT_COLUMN } from '@birtalanrobert/database';
3
+ import type { PaymentKind, PaymentMethod, PaymentState } from '../payments';
3
4
 
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.
5
+ /*
6
+ * The lifecycle types live in the pure entry point and are re-exported here.
18
7
  *
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.
8
+ * A console renders a refund button from the same union the entity is typed
9
+ * with, and it must not reach TypeORM to get it.
22
10
  */
23
- export type PaymentState =
24
- | 'pending'
25
- | 'authorized'
26
- | 'captured'
27
- | 'failed'
28
- | 'refunded'
29
- | 'partially_refunded'
30
- | 'cancelled';
11
+ export type { PaymentKind, PaymentMethod, PaymentState } from '../payments';
31
12
 
32
13
  /**
33
14
  * One movement of money between a customer and a business.
@@ -58,6 +39,17 @@ export class Payment extends BaseEntity {
58
39
  @Column('varchar', { length: 16 })
59
40
  method!: PaymentMethod;
60
41
 
42
+ /**
43
+ * What it was for.
44
+ *
45
+ * A tip belongs to the person who earned it and a product to neither the
46
+ * service nor the diary — a report counting all three as service income tells
47
+ * a business its haircuts are more profitable than they are, and no amount of
48
+ * arithmetic afterwards can separate them again.
49
+ */
50
+ @Column('varchar', { length: 16, default: 'sale' })
51
+ kind!: PaymentKind;
52
+
61
53
  @Column('varchar', { length: 24, default: 'pending' })
62
54
  state!: PaymentState;
63
55
 
@@ -0,0 +1,69 @@
1
+ import { Column, Entity, Index, Unique } from 'typeorm';
2
+ import { BaseEntity } from '@birtalanrobert/database';
3
+
4
+ /**
5
+ * A card kept for later, with the words the customer agreed to.
6
+ *
7
+ * **The strongest thing a business can do about people not turning up, short of
8
+ * taking their money.** Nothing leaves the customer's account when they book;
9
+ * the card is simply there if a fee is later decided on — and the decision is
10
+ * still a human one made days afterwards, which is exactly when asking somebody
11
+ * to enter a card is a conversation that does not happen.
12
+ *
13
+ * A hold is not a substitute and must not be sold as one: providers expire an
14
+ * authorisation within days, and an appointment is usually further away.
15
+ *
16
+ * **The consent is stored as text, not as a flag.** A boolean records that
17
+ * somebody clicked; the sentence records what they were told they were agreeing
18
+ * to, which is the only thing worth anything when they say they were not.
19
+ */
20
+ @Entity('mortar_saved_cards')
21
+ @Unique('uq_saved_cards_tenant_id', ['tenantId', 'id'])
22
+ @Index('ix_saved_cards_subject', ['tenantId', 'subject'])
23
+ export class SavedCard extends BaseEntity {
24
+ @Column('uuid')
25
+ tenantId!: string;
26
+
27
+ /**
28
+ * Whose card it is, as the owning product names it: `customer:<id>`.
29
+ *
30
+ * No foreign key, for the same reason a payment has none — one product hangs
31
+ * a card off a salon's client, another off a tenant's guest.
32
+ */
33
+ @Column('varchar', { length: 160 })
34
+ subject!: string;
35
+
36
+ @Column('varchar', { length: 32 })
37
+ provider!: string;
38
+
39
+ /** The customer on *our* account, which is where a saved card can be used. */
40
+ @Column('varchar', { length: 128 })
41
+ customerRef!: string;
42
+
43
+ @Column('varchar', { length: 128 })
44
+ paymentMethodRef!: string;
45
+
46
+ /** Enough to recognise it — "Visa ending 4242" — and nothing more. */
47
+ @Column('varchar', { length: 24, nullable: true })
48
+ brand!: string | null;
49
+
50
+ @Column('varchar', { length: 4, nullable: true })
51
+ last4!: string | null;
52
+
53
+ @Column('int', { name: 'expiry_month', nullable: true })
54
+ expiryMonth!: number | null;
55
+
56
+ @Column('int', { name: 'expiry_year', nullable: true })
57
+ expiryYear!: number | null;
58
+
59
+ /** What they were told they were agreeing to, in the language they read it. */
60
+ @Column('varchar', { length: 1000 })
61
+ consentText!: string;
62
+
63
+ @Column('timestamptz')
64
+ consentedAt!: Date;
65
+
66
+ /** Who stored it, where a member of staff did it on somebody's behalf. */
67
+ @Column('uuid', { nullable: true })
68
+ storedBy!: string | null;
69
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Where a payment is, and what may still be done to it.
3
+ *
4
+ * Pure and dependency-free, because a console renders a refund button from
5
+ * exactly the rule the server enforces — and a screen that offers a refund the
6
+ * server will refuse is a screen that teaches its user not to trust it.
7
+ */
8
+
9
+ /**
10
+ * How the money arrived.
11
+ *
12
+ * `card` is the only one this package processes. The rest are **recorded, not
13
+ * taken** — and recording them is not a lesser feature: a salon is mostly cash
14
+ * at the counter, a restaurant's till takes meal vouchers, and a box office
15
+ * takes notes. A report that only counts what a provider processed tells a
16
+ * business a fraction of its own takings, which is worse than telling it
17
+ * nothing because it looks complete.
18
+ */
19
+ export type PaymentMethod = 'card' | 'cash' | 'terminal' | 'voucher' | 'transfer';
20
+
21
+ /**
22
+ * Where a payment is.
23
+ *
24
+ * `authorized` is separate from `captured` on purpose: a card held against a
25
+ * no-show fee is authorised and never captured unless the fee is applied, and
26
+ * that decision is a human one.
27
+ */
28
+ export type PaymentState =
29
+ | 'pending'
30
+ | 'authorized'
31
+ | 'captured'
32
+ | 'failed'
33
+ | 'refunded'
34
+ | 'partially_refunded'
35
+ | 'cancelled';
36
+
37
+ /**
38
+ * What a payment was for.
39
+ *
40
+ * Not decoration: a tip belongs to the person who earned it and a product to
41
+ * neither the service nor the diary, so a revenue report that counts all three
42
+ * as service income tells a business its haircuts are more profitable than they
43
+ * are. The distinction has to survive into the row, because it cannot be
44
+ * recovered from an amount afterwards.
45
+ */
46
+ export type PaymentKind = 'sale' | 'deposit' | 'fee' | 'tip' | 'product';
47
+
48
+ /**
49
+ * Whether money actually moved, and so can be given back.
50
+ *
51
+ * A held card has taken nothing yet — releasing it is a different operation
52
+ * with a different name — and a failed charge never will. Both look refundable
53
+ * to anyone reasoning from "there is a payment row here", which is why this is
54
+ * one function and not a condition written twice.
55
+ */
56
+ export const isRefundable = (state: PaymentState): boolean =>
57
+ state === 'captured' || state === 'partially_refunded';
58
+
59
+ /**
60
+ * What is left to give back, in minor units.
61
+ *
62
+ * Takes the two amounts rather than a row, because they arrive as strings from
63
+ * a `bigint` column in one caller and as numbers in another, and the subtraction
64
+ * should not be where that difference is discovered.
65
+ */
66
+ export const refundableAmount = (amount: number, refunded: number): number =>
67
+ Math.max(0, amount - refunded);
@@ -28,6 +28,20 @@ export interface OnboardingLink {
28
28
  export interface ChargeRequest {
29
29
  /** The business being paid, as the provider knows it. */
30
30
  readonly account: string;
31
+ /**
32
+ * The customer, where one is being charged again rather than for the first
33
+ * time. Held on *our* account rather than the business's, because that is
34
+ * where a saved card lives under a destination charge.
35
+ */
36
+ readonly customer?: string;
37
+ /**
38
+ * A card already saved, to charge without anybody present.
39
+ *
40
+ * The whole point of storing one: a no-show fee is decided days later, and
41
+ * asking somebody who did not turn up to enter a card is a conversation that
42
+ * does not happen.
43
+ */
44
+ readonly paymentMethod?: string;
31
45
  readonly amount: number;
32
46
  readonly currency: string;
33
47
  /** Our cut, taken on top rather than out of the business's money. */
@@ -57,10 +71,48 @@ export interface ChargeResult {
57
71
  * failure and must not be handled as one.
58
72
  */
59
73
  readonly redirectUrl?: string;
74
+ /**
75
+ * What the browser needs to finish paying, when the customer is present.
76
+ *
77
+ * A charge created on the server has no card attached to it yet — the card is
78
+ * entered in a browser, against the provider's own script, so that the number
79
+ * never reaches us. Without this the payment can be created and never paid,
80
+ * which is the state a customer reads as "it took my booking and lost my
81
+ * money".
82
+ */
83
+ readonly clientSecret?: string;
60
84
  readonly instrument?: string;
61
85
  readonly detail?: string;
62
86
  }
63
87
 
88
+ /** A card being stored for later, rather than charged now. */
89
+ export interface SaveCardRequest {
90
+ /** An existing customer to attach it to, where the person already has one. */
91
+ readonly customer?: string;
92
+ /** What it is being saved for, carried through for matching a webhook back. */
93
+ readonly subject: string;
94
+ readonly reference: string;
95
+ }
96
+
97
+ export interface SaveCardResult {
98
+ /** The customer the card will hang off, created here if there was none. */
99
+ readonly customer: string;
100
+ /** The provider's handle on this attempt, to read the result back from. */
101
+ readonly externalId: string;
102
+ /** What the browser confirms against. */
103
+ readonly clientSecret: string;
104
+ }
105
+
106
+ /** A card that was actually stored, read back after the browser confirmed it. */
107
+ export interface StoredCard {
108
+ readonly customer: string;
109
+ readonly paymentMethod: string;
110
+ readonly brand?: string;
111
+ readonly last4?: string;
112
+ readonly expiryMonth?: number;
113
+ readonly expiryYear?: number;
114
+ }
115
+
64
116
  export interface RefundRequest {
65
117
  readonly externalId: string;
66
118
  readonly amount: number;
@@ -95,6 +147,28 @@ export interface PaymentProvider {
95
147
  /** Releases a hold without taking anything. */
96
148
  release(externalId: string): Promise<void>;
97
149
 
150
+ /**
151
+ * Starts storing a card without charging it.
152
+ *
153
+ * The strongest thing a business can do about no-shows short of taking money:
154
+ * nothing leaves the customer's account, and the card is there if a fee is
155
+ * later decided on. A hold is not a substitute — providers expire one within
156
+ * days, and an appointment is usually further away than that.
157
+ */
158
+ saveCard(request: SaveCardRequest): Promise<SaveCardResult>;
159
+
160
+ /**
161
+ * What was actually stored, once the browser says it finished.
162
+ *
163
+ * Read back from the provider rather than believed from the browser: what a
164
+ * page reports is what a page was told to report, and a saved card is
165
+ * something a business will later charge money against.
166
+ */
167
+ storedCard(externalId: string): Promise<StoredCard | undefined>;
168
+
169
+ /** Forgets a stored card, at the customer's request or the business's. */
170
+ forgetCard(paymentMethod: string): Promise<void>;
171
+
98
172
  refund(request: RefundRequest): Promise<{ externalId: string }>;
99
173
 
100
174
  /**