Skip to main content

Payment Gateway

Contract Billing Core defines a PaymentGateway interface that abstracts payment processing. You implement this interface for your payment provider (Stripe, Braintree, PayPay, etc.).

Canonical reference

This page is an English summary. The canonical, in-depth specification is docs/internals/payment-gateway.md (see the Documentation Map). When details differ, the internals spec and the code win.

Interface Overview

The gateway supports direct charge, two-phase (authorize/capture), refunds, and payment method management:

type PaymentGateway interface {
ID() string
SupportedMethods() []PaymentMethodType

Charge(ctx, req *ChargeRequest) (*ChargeResponse, error)
Authorize(ctx, req *AuthorizeRequest) (*AuthorizeResponse, error)
Capture(ctx, req *CaptureRequest) (*CaptureResponse, error)
Void(ctx, req *VoidRequest) (*VoidResponse, error)
Refund(ctx, req *RefundRequest) (*RefundResponse, error)
Cancel(ctx, req *CancelRequest) (*CancelResponse, error)
GetTransaction(ctx, transactionID string) (*Transaction, error)

RegisterPaymentMethod(ctx, req) (*PaymentMethodDetail, error)
DeletePaymentMethod(ctx, paymentMethodID string) error
GetPaymentMethod(ctx, paymentMethodID string) (*PaymentMethodDetail, error)
ListPaymentMethods(ctx, customerID string) ([]*PaymentMethodDetail, error)
}

Supported payment methods: credit card, debit card, bank transfer, convenience store, QR code, carrier, postpay, direct debit.

Payment Flows

Direct Charge

The simplest flow — charge immediately:

resp, err := gateway.Charge(ctx, &port.ChargeRequest{
CustomerID: "cust-001",
Amount: invoiceTotal,
PaymentMethodID: &pmID,
IdempotencyKey: "charge-inv-001",
})

Authorize/Capture

For cases where you need to reserve funds before finalizing:

1. Authorize → reserve funds
2. Provision service
3. Capture → finalize charge (can be partial)

Asynchronous Settlement (Bank Transfer, Konbini, Carrier)

Some methods issue a payment instruction and settle later, when the customer pays out-of-band. When the gateway's ChargeResponse.Status is pending, PaymentService.ProcessPayment persists a Pending payment (idempotency key, gateway transaction ID, resolved method), leaves the invoice unpaid, and returns the sentinel service.ErrPaymentPending (check with errors.Is). Customer-facing payment instructions returned by the adapter (ChargeResponse.Instructions: voucher URL, reference, deadline) are persisted on the pending payment's metadata under the reserved payment.MetadataKeyInstructions* keys, so you can surface them to the customer. From your webhook handling:

  • payment.received → call PaymentService.SettlePayment(ctx, paymentID): completes the payment and marks the invoice paid in one transaction (outbox writer fires in-tx; AfterCharge/OnPaymentProcessed hooks fire post-commit). Replays are idempotent no-ops; settling a terminal payment is rejected.
  • Instruction expired → call PaymentService.MarkPaymentFailed(ctx, paymentID, reason): Pending → Failed, invoice untouched, OnPaymentFailed hooks fire (non-fatal).

See the canonical spec, §6.5 of docs/internals/payment-gateway.md, for the full semantics.

Payment Method Fallback Resolution

Payment methods are resolved hierarchically:

1. Explicit PaymentMethodID in ProcessPaymentInput
2. Invoice.PaymentMethodID
3. Contract.PaymentMethodID
4. Customer.DefaultPaymentMethodID

More specific levels override less specific ones. This enables per-contract payment methods (B2B), auto-charge without specifying a method, and dunning retry with updated defaults.

Payment-Gated Provisioning

A recommended pattern for services where access depends on payment:

StepContract StatusAction
1DraftCreate contract
2ActiveActivate
3SuspendedImmediately suspend (awaiting payment)
4Generate and finalize invoice
5Process payment
6ActiveResume → Provision service

This reuses the Suspended state for both initial activation and non-payment suspension. Both resolve the same way: payment → resume.

Known Limitation

OnContractResumeHook cannot distinguish between initial activation and re-activation because SuspensionConfiguration is cleared before the hook fires. Track provisioning state externally to handle this.

For implementation details and code examples, see the Payment Integration Guide.

Next Steps